SSR / foldkit-ssr

Serving pages

Through Foldkit's fetch handler

Foldkit's server entry is one function, renderPage(request), which handleRequest calls for every request that is not a static file, on Node and on Workers alike. SSR.entry is that function for a plan. The host owns the template: the entry answers Rendered, with the envelope on the rendered root, and the host injects it into its own template, in development through the foldkit Vite plugin and in production through the fetch handler a vite build emits.

ts
import { handleRequest } from 'foldkit/experimental/server'

// The server entry.
export const { renderPage } = SSR.entry(config, Post, { buildId })

// A Worker, or any host that hands you a Web Request.
export default {
  fetch: (request: Request) => handleRequest(request, { renderPage, template }),
}
ts
// vite.config.ts, for the dev server and the build id both entries compile in.
import { foldkit } from '@foldkit/vite-plugin'

export default defineConfig({
  plugins: [foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } })],
})
ts
// entry.server.ts and the browser's entry.ts alike.
buildId: import.meta.env.FOLDKIT_BUILD_ID,

FOLDKIT_BUILD_ID names the deployment, from the environment: a commit or a release tag, public and unique per deployment. The plugin compiles it into both bundles; a script outside Vite, such as a prerender, reads process.env.FOLDKIT_BUILD_ID, the same value the build saw.

Set it even though @foldkit/vite-plugin can now generate an id on its own. The generated one is compiled into Foldkit alone, where foldkit-ssr cannot read it, and the client must compare builds before it reads a page's payload, so a Model serialized by another deployment is never decoded. Asking Foldkit to expose that id is proposal 4.

GET and HEAD render the page for the request's URL; an application with Flags passes flags: request => .... POST is handled only for a plan with a server fallback. OPTIONS is answered 204 and any other method 405, each with the methods the entry answers in allow. A render that fails or throws, flags that reject, and a plan the render refuses are answered 500 with the reason logged, never with a page the browser could not resume.

headers: request => ... rides on the Rendered result, set over the host's own: a cache policy for a page that is one visitor's, or the CORS answer to a preflight, which reaches the entry and not the host.

ts
SSR.entry(config, Post, {
  buildId,
  headers: () => ({ 'cache-control': 'private, no-store', vary: 'cookie' }),
})

A headers that throws is answered 500, as a failed render is.

What the page says of itself (meta) and per-page head markup are written into a template's head, which the host owns here: a plan with meta is refused when the entry is made, and served with SSR.generate below, which owns its template. foldkit#1448 asks Foldkit for a per-request head channel beside the envelope.

At build time

ts
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'

const pages = await Effect.runPromise(
  SSR.generate(config, Post, {
    buildId,
    template,
    origin: 'https://example.com',
    paths: ['/', '/about', '/docs/intro'],
  }),
)
for (const page of pages) {
  await mkdir(dirname(join('dist', page.file)), { recursive: true })
  await writeFile(join('dist', page.file), page.html)
}

Each path becomes index.html in its own folder (/about is about/index.html); a path ending in .html keeps its name. origin makes each path the full URL a routing application parses, and flags: path => ... gives each its own Flags. The pages come back in the order of paths, typed as a tuple, so const [home, about] = pages needs no check.

A static host serves one file for every query, and for /about and /about/ alike, so a generated page records its path alone: /about?ref=mail resumes the page generated for /about, and /other is refused. A server-rendered page records its path and query, and resumes only there.

A generated site also wants a sitemap and a robots.txt that names it. SSR.sitemap takes the pages, with a modified date where one is known, and SSR.robots the origin:

ts
await writeFile(
  'dist/sitemap.xml',
  SSR.sitemap(
    pages.map(page => ({ path: page.path, modified: '2026-09-27' })),
    { origin: 'https://example.com' },
  ),
)
await writeFile('dist/robots.txt', SSR.robots({ origin: 'https://example.com' }))

modified is any date Date reads, written as its UTC day. A path listed twice, one not starting with /, or a date Date cannot read is refused rather than written into a sitemap a search engine would reject. robots takes disallow paths and a sitemap path other than /sitemap.xml.

As a Vite plugin: foldkit-ssr/vite

foldkit-ssr/vite runs the same static build as a closeBundle step on the application's own vite build, so a site is generated without a second script. The application describes its site in a module (src/site.ts) and the plugin evaluates it through a server, so vite.config.ts names a file, never application code:

ts
// vite.config.ts
import { staticSite } from 'foldkit-ssr/vite'
export default defineConfig({
  plugins: [foldkit(), staticSite({ site: { module: '/src/site.ts' } })],
})

src/site.ts exports a SiteModule: what SSR.generate takes, plus where the pages land. config may be one config or a function of the path (a site whose Model is prepared per page returns its own); paths may be a thunk that seeds and lists; head is the same head; and sitemap/robots write their files from the generated pages. files is the host's layout — 'directory' (/about as about/index.html, the default) or 'flat' (about.html, for a host with clean addresses and no directory indexes):

ts
// src/site.ts
import type { SiteModule } from 'foldkit-ssr/vite'
export const site = {
  origin: 'https://example.com',
  paths: ['/', '/about'],
  config: { Model, init, update, view, container: null, routing },
  plan,
  head: rendered => `<style>${Style.usedIn(rendered.html)}</style>`,
  files: 'directory',
  sitemap: true,
  robots: true,
} satisfies SiteModule

generateStaticSite(site) is the same build without Vite: it renders every path and returns { pages, sitemap?, robots? }. fileFor(path, layout) maps one path to the file a host serves it from, so a caller writes the files itself; generateStaticSite refuses two paths that would claim one file.