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.
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 }),
}// 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' } })],
})// 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.
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
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:
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:
// 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):
// 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 SiteModulegenerateStaticSite(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.