On this pageOverview
Server Rendering
Experimental
Server rendering ships from foldkit/experimental while the API settles, and any release may change it. Please try it and report what breaks.
Foldkit renders on the server with the same program the browser runs. renderToString resolves init, runs the pure view, and returns HTML. Runtime.hydrate then adopts that HTML in place. The same init, view, update, and Model work whether the HTML was rendered during a build (SSG) or while handling a request (SSR).
That gives Foldkit one rendering pipeline with two delivery policies:
Static site generation (SSG): a build script renders a finite set of URLs and writes HTML files.
Server-side rendering (SSR): a server renders a URL when its request arrives.
An application can use either policy, or use SSG for some URLs and SSR for others. The application code does not need a second rendering API.
One page, end to end, grouped by where each step runs and tagged with who supplies it:
GET /page (from the browser)
│
▼
── on the host ── Vite in dev, your server in production
renderPage [you write]
derives Flags from the request
renderToString [Foldkit]
calls your init, runs your view → HTML
toResponse [Foldkit]
fills index.html, embeds the Flags payload
│
▼ HTML page + Flags payload
── on the browser ──
Runtime.hydrate [Foldkit]
reads the Flags, calls the same init, adopts the DOM
live application
listeners and Mounts attach to the existing elementsOnce the live application takes over, the page is an ordinary Foldkit application: routing, update, Commands, and Subscriptions all run in the browser, and navigation moves between routes without contacting the server. The server renders again only on the next full page load, such as a reload or a link the runtime does not handle.
For SSG the picture is identical with one substitution: the host is a build script, and the response is written to a file that a static server or CDN delivers later.
A server entry is the module that sits between the application and its host. It exports a renderPage function that accepts a Web Request and returns a Promise<EntryResult>:
import { Effect } from 'effect'
import { Server } from 'foldkit/experimental'
import { readCountCookie } from './cookie'
import { Flags, init, view } from './main'
const flagsForRequest = (request: Request): Flags => ({
initialCount: readCountCookie(request.headers.get('cookie') ?? ''),
})
export const renderPage = (request: Request): Promise<Server.EntryResult> =>
Effect.runPromise(
Effect.gen(function* () {
const renderedApplication = yield* Server.renderToString(
{ Flags, init, view },
{ flags: flagsForRequest(request) },
)
return Server.Rendered(renderedApplication, {
headers: {
'cache-control': 'private, no-store',
vary: 'cookie',
},
})
}),
)The outer Promise keeps renderPage callable from Vite, build scripts, serverless functions, and long-running Effect HTTP servers without making those hosts provide the application's Effect requirements. The entry uses Effect internally; hosts see only the Promise.
The entry is application code, so it lives in src/ (src/entry.server.ts in the examples), not in the host's directory. It imports the app's init, view, and Flags and renders them, so it must build into the same module graph as the client. Foldkit's HTML builder uses a module-local render frame, and the view and renderToString share that frame only when they resolve to the same Foldkit module instance, which is what bundling the entry with the app guarantees. A host imports the built entry and calls renderPage; it never imports the application and renders it itself. The SSR example's delivery host lives outside src/, in server/, and does exactly that.
renderToString accepts the server-relevant subset of a makeApplication config: the init and view fields, plus Flags and routing when the application declares them. A full application config satisfies that subset, so an entry may pass the application's config object unchanged. The container, update, subscriptions, and managedResources fields do not participate in the server render: the render runs the view once over the Model init returns, with no DOM to attach to and no Messages to dispatch.
For a routing application, pass the request URL so init receives it exactly as it receives window.location in the browser:
Server.renderToString(config, { url: request.url, flags })A server entry returns one of two variants:
Server.Rendered(application, options)asks the host to place Foldkit's rendered application in its HTML template. Its options can carry an HTTP status and headers.Server.Responded(response)bypasses template injection with a complete WebResponse. Use it for redirects and other non-page responses the page request itself resolves without rendering the shell.
Server.toResponse(template, result) folds either variant into the Web Response the host sends, so a request-time host handles both cases with one call instead of matching on the result itself. It injects a Rendered application into the template, defaults to status 200 and a UTF-8 HTML content type, and passes a Responded response through unchanged.
The render host serves pages, not a data API. When a client needs JSON, put that endpoint on a separate backend (an Effect HttpApi service is the natural fit) rather than answering it from the server entry. Foldkit keeps the line between server and client at the data: the backend returns data, the frontend renders it.
The rendered application contains the body markup and the Document's initial head state:
type RenderedApplication = Readonly<{
html: string
title: string
lang?: string
dir?: 'ltr' | 'rtl' | 'auto'
canonical?: string
ogUrl?: string
}>injectIntoTemplate places that output into a standard index.html. It expects exactly one <div id="root"></div> placeholder by default, with no extra attributes or inner whitespace, plus exactly one <title> element. Pass containerId when the template uses another id. It also stamps language, text direction, canonical URL, and Open Graph URL into matching shell elements.
A hydratable render carries two markers:
The application root has
data-foldkit-app. Its value identifies the runtime.A Flags application also emits a JSON script carrying the Schema-encoded Flags that produced the server Model.
Conceptually, the handoff appears next to the rendered root:
<main data-foldkit-app="app"><!-- rendered view --></main>
<script type="application/json" data-foldkit-flags="app">
{ "initialCount": 2 }
</script>The script type makes the payload data rather than executable JavaScript. Foldkit escapes values that could close the script element, then parses and Schema-decodes the text during hydration. Flags are public HTML, not a place for secrets.
The client opts into the handoff explicitly, in the client entry (src/entry.ts in the examples):
Runtime.hydrate(application)Runtime.run always builds the DOM fresh. A Flags application supplies its client-only Flags Effect at that boundary:
Runtime.run(application, { flags })Runtime.hydrate accepts no client Flags producer. It reads the serialized Flags, calls the same init, and adopts matching server DOM nodes rather than replacing them. Element identity, focus, scroll position, and media state survive while listeners and Mounts attach. This adoption is the hydration step. A mismatching subtree rebuilds at the nearest parent, with a development warning that points back to nondeterministic Flags, init, or view output.
Calling hydrate declares that a complete server handoff exists. A missing stamped root, missing Flags payload, or undecodable Flags payload terminates startup and leaves the rendered HTML visible but inert. The failure is a descriptive [foldkit] error logged through the runtime's error reporting, where the console and error monitoring pick it up; there is no application-level hook because startup never reaches a Model. This catches stale HTML, mismatched client bundles, and deployment mistakes instead of silently booting a different client Model. Use run in a separate client entry when a page must support a fresh SPA boot.
One development-only exception: when a hot update restarts the runtime, the Model HMR preserved wins and the view rebuilds from it instead of adopting the served DOM, since that DOM was rendered by code from before the update. The handoff contract still holds, so the stamped server root must exist.
Commands returned by init do not run during renderToString. The server output represents the immediate post-init Model. After hydration, the browser runs those Commands normally.
Hydration requires both sides to construct the same first Model. Flags embedded in HTML are the contract that makes this reproducible.
For request-time SSR, Flags can come from the request, including the URL, headers, and cookies. For build-time SSG, Flags must be universal and build-stable because one file is served to every visitor.
Flags are public
Every serialized Flag ships in the page's HTML. Never place credentials, private tokens, or other secrets in Flags.
Browser-only facts do not belong in hydratable SSG Flags. For example: a theme stored in localStorage, the viewport width, or browser feature detection cannot be known by the build. Start from a neutral Model on both sides, then load those facts through a boot-time Command or Subscription after hydration. If a preference must affect the server HTML, promote it to request-visible data such as a cookie and use request-time SSR for that URL.
isHydratable defaults to true for both SSG and SSR. Pass isHydratable: false only when producing static markup that will not be hydrated. It is not the normal SSG setting.
In development, enable the Vite host in vite.config.ts:
foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } })Vite continues to serve the client entry, HMR, and assets. Requests that reach the Foldkit middleware are converted to Web Request values and passed to renderPage; the returned Web Response supplies the status, headers, and body.
In production, build the client and the server host separately. The host serves static assets first, imports the built server entry, and sends Server.toResponse(template, await renderPage(request)). The SSR example uses an Effect HttpServer for that thin delivery layer.
Caching personalized responses
When Flags depend on the request, such as a cookie, an authorization header, or locale, the rendered HTML is specific to that visitor. Set cache-control and vary so it cannot be stored in a shared cache and served to someone else. The SSR example uses private, no-store and vary: cookie because its initial count comes from a cookie.
An SSG host builds the browser bundle and server entry, then calls renderPage once for every generated URL from a build script (scripts/prerender.ts in the SSG example):
for (const path of prerenderPaths) {
const request = new Request(`https://example.com${path}`)
const result = await serverEntry.renderPage(request)
if (result._tag === 'Responded') {
throw new Error(`Cannot write a Response for ${path} as static HTML`)
}
const html = Server.injectIntoTemplate(template, result.application)
await writeRoute(path, html)
}A static file cannot preserve arbitrary response behavior. The build should reject Responded results and rendered statuses it cannot reproduce rather than silently discarding them.
The SSG example is the minimal reference. This website is the production-scale reference: its prerender host uses the same renderPage(Request) contract, seeds route content through universal Flags, and writes every route as hydratable static HTML.
A deployed SSG build is static files. Any static host or CDN serves the generated directory as is; the hydration handoff already lives inside the HTML, so nothing beyond serving files is required.
A deployed SSR application needs a host with two jobs: serve the built client assets, and call renderPage for page requests. On Node, the SSR example's server is the reference: an Effect HttpServer that serves static files first and sends Server.toResponse(template, await renderPage(request)) for everything else.
Behind a CDN or reverse proxy, a route whose Flags read from the request produces visitor-specific HTML. Keep those responses out of shared caches with cache-control and vary, so an edge cache never serves one visitor's page to another.
Fetch-native runtimes such as Cloudflare Workers, Deno, and Bun run the entry without an adapter, because those platforms already speak Web Request and Response:
import { Server } from 'foldkit/experimental'
import template from './dist/client/index.html'
import { renderPage } from './dist/server/entry.server'
export default {
fetch: async (request: Request): Promise<Response> =>
Server.toResponse(template, await renderPage(request)),
}The platform serves the built client assets, and the handler covers page requests. The template import works because the bundler is told to treat .html imports as plain strings; Cloudflare's Wrangler CLI, the tool that builds and deploys Workers, calls this a Text module rule. The same built server entry runs unchanged on each of these hosts.
To provision and deploy the host itself, Alchemy is the natural fit. It is TypeScript-native infrastructure as code built on Effect, so the Worker and the resources it binds to, such as a database, object storage, or a queue, are declared in the same TypeScript program that ships the entry, with no separate configuration language to keep in sync. Its Cloudflare support deploys the Worker directly, which pairs naturally with the fetch-native host above.
SSG and SSR are route delivery policies, not separate Foldkit application types. A hybrid deployment can generate stable routes during the build and send the remaining URLs to a request-time host. Both hosts import the same server entry, and every delivered page hydrates through the same client entry.
For example: product documentation and marketing pages can be generated at build time, while account pages and preview URLs render per request. A route should have one authoritative delivery policy in a deployment so the CDN and runtime server do not disagree about which version owns it.
Commands do not run during a server render, so a page whose data arrives through a Command renders the Model's pre-Command state, usually a loading state. Supply the data through Flags when the server HTML must include it.
Components that measure the DOM to decide what to render, such as
Ui.VirtualList, render their initial unmeasured state and fill in after hydration.makeElementandembedapplications do not hydrate yet. Server rendering is for page-owningmakeApplicationprograms.A custom element that upgrades before hydration keeps the attributes its
connectedCallbackadds that the view does not declare on the element. When the view drives class and style throughh.Classandh.Style, component-added class tokens and style properties the view does not declare are kept as well; a rawh.Attribute('class', ...)orh.Attribute('style', ...)instead owns the whole attribute and replaces it, dropping component additions. Component-built light DOM is preserved only when the view declares no children for the element; a view that declares children owns the element's light DOM.Text typed into a controlled input before hydration yields to the Model when controlled values are reasserted. The element, focus, and page scroll survive.
Arbitrary properties set through
h.Propare client behavior rather than markup, so they take effect after hydration. A<select>value comes from its selected<option>in server HTML and settles from the Model after hydration.Raw-text elements such as
scriptandstylecannot contain their literal closing-tag sequence.renderToStringrejects content that would break out of the element.