0010: Static site generation driver
Status: Accepted Date: 2026-05-10
Context
renderToString (ADR 0004) renders a Purity
component to HTML for one request. renderToStream (ADR
0006) does the same with a streaming
output. Both are designed for live request handling — the runtime
calls them inside the HTTP handler and returns the result to the user.
A second mode is also useful: render a list of routes at build time,
write the HTML to disk, serve it statically from a CDN. This is the
classic Static Site Generation (SSG) flow popularised by Next, Astro,
Eleventy, and Gatsby. It pairs especially well with Purity's
zero-runtime-cost philosophy — the entire application's HTML can ship
as plain files, with hydrate() taking over for any reactive bits
when the user actually clicks something.
Nothing in renderToString blocks SSG today. A user can write a
top-level loop:
for (const path of routes) {
const html = await renderToString(() => App({ path }));
await writeFile(out(path), html);
}
But that loop misses several details that recurring SSG drivers provide:
- Per-route
Request. ADR 0009 gave components access to the incomingRequestviagetRequest(). Static renders also have a "request" — synthesised frombaseUrl + path— and components should see it through the same API so route-aware code paths (canonical URLs, language detection,if (path === '/blog/*') …) work uniformly. head()capture. ADR 0008 shipshead()withrenderToString({ extractHead: true }). SSG always wantsextractHead: trueso the per-route<title>and<meta>tags land in the output's<head>.- Shell templating. Every output file needs the same
<html><head><body>shell. The driver should splice body + head into a user-supplied template so the user only writes it once. - Error isolation. One bad route shouldn't abort the whole batch — common for SSG'ing 10k+ marketing pages where a single stale fetch URL would otherwise fail the entire build.
- Bounded concurrency. Naïve
Promise.all(routes.map(…))opens one render per route. For thousands of routes that opens thousands of concurrent fetches; memory and socket budgets blow up. A concurrency cap is the standard fix. - Filesystem I/O is platform-specific. Node has
fs/promises, Bun and Deno have their own. Each user's deploy target may want different filename conventions (index.htmlvspath.html). The driver shouldn't pick.
These are individually small, but together represent enough boilerplate that every Purity user shipping a static site would re-implement them. A focused driver is the right shape.
Decision
Add renderStatic(options) to @purityjs/ssr. It iterates a list
of routes, synthesises a Request for each, runs renderToString
with { extractHead: true, request }, splices the body + head into
an optional shell template, and returns the final HTML strings as a
Map<path, html> — without touching the filesystem.
import { renderStatic } from '@purityjs/ssr';
import { writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const { files, errors } = await renderStatic({
routes: ['/', '/about', '/blog/hello-world'],
handler: (req) => () => App({ url: req.url }),
shellTemplate:
'<!doctype html><html><head>{{head}}</head>' +
'<body><div id="app">{{body}}</div></body></html>',
baseUrl: 'https://example.com',
concurrency: 8,
});
for (const [route, html] of files) {
const out = join('dist', route === '/' ? 'index.html' : `${route.replace(/^\//, '')}/index.html`);
await mkdir(dirname(out), { recursive: true });
await writeFile(out, html);
}
for (const [route, err] of errors) console.error('SSG failure:', route, err);
Concretely:
routes: ReadonlyArray<string | RenderStaticRoute>— list of URL paths to render.RenderStaticRouteis{ path, request? }; supplying arequestlets the caller pass a fully-constructedRequest(custom headers, method, etc.) instead of letting the driver synthesise one frombaseUrl + path.handler(req: Request) => () => unknown— resolves a request to the component thunk the renderer should invoke. The thunk shape (() => unknown) is the same onerenderToStringaccepts, so the handler is the only piece of route-dispatch the user writes.shellTemplate?: string— optional.{{body}}is replaced with the rendered HTML;{{head}}is replaced with thehead()-collected markup (or empty ifhead()wasn't called). If{{head}}is absent and head markup exists, the head is prepended to the body so it isn't silently lost. If the template itself is omitted, the rendered body is returned as-is.baseUrl?: string— base for synthesising per-route requests. Default'http://localhost'. Set to your production origin so components computing canonical URLs viareq.urlget the right hostname during SSG.doctype?: string,renderOptions?: …— forwarded torenderToString.renderOptionsexcludesextractHead(always forced totrue) andrequest(supplied per-route).concurrency?: number— render in parallel up to this cap. Default unbounded. A small bounded worker pool drains the route list deterministically.onRoute?: (path, html) => void | Promise<void>— called per route as it completes. Useful for streaming output to disk one route at a time instead of holding the entirefilesmap in memory.Map<path, html>return. Successful renders only. Failures go toerrors: Map<path, unknown>so the build script can decide whether to fail the deploy or publish what made it through.
The driver does not touch the filesystem. That responsibility
stays with the caller — Node, Bun, and Deno each have their own
fs/promises (or Deno.writeTextFile), and the file-layout
convention (/blog/hello.html vs /blog/hello/index.html) is a
user decision. A six-line user loop closes the I/O side.
Consequences
Positive:
- One composable function fully covers the SSG use case. The
primitives below it (
renderToString,head(),getRequest()) are already shipping;renderStaticis mostly orchestration. - Identical render path between SSG and live SSR. A page that
renders correctly under
renderToStringrenders correctly underrenderStatic—getRequest()works,head()works,resource()awaiting works, suspense boundaries resolve buffered (same as the buffered renderer). - Runtime-agnostic. No
node:fsimport. Bun, Deno, even running SSG from the browser (writing files to an OPFS-backed virtual FS) all work the same way. - Errors are per-route. SSG'ing 10k marketing pages doesn't fail the build for one stale fetch.
Negative:
- The caller writes the filesystem loop. Six lines of
writeFile + mkdirper project. Acceptable given the cross-runtime portability win. - Routing is still flat. SSG works for "I know my route list ahead
of time" sites; dynamic route generation (sitemap from a CMS,
paginated archives) is the user's concern. Future ADRs may add a
getRoutes()discovery primitive when filesystem-based routing lands. Map<path, html>is held in memory for the full batch whenonRouteisn't used. For very large sites users should passonRouteand stream to disk; we don't enforce it.
Neutral:
concurrency: 1deserialises the renders, matching what users expect for debugging or CI memory caps.concurrency: Infinity(the default) is fast for typical sites (< 1 000 routes) and easy to override.- Shell template uses
{{body}}/{{head}}placeholders rather than HTML comment markers. Pragma: build-time templating is the user's familiar pattern (Mustache, Handlebars, EJS) and a literal string replacement avoids parser surface. The marker grammar Purity uses internally (<!--[-->,<!--s:N-->, etc.) is reserved for runtime hydration semantics, not user-facing templating.
Alternatives considered
A CLI subcommand (purity build:static). Would auto-detect
routes, run the renders, and write files. Rejected for Phase 1:
forces a routing convention before file-system routing has its own
ADR, and CLIs tend to harden the wrong defaults early. A focused
library function is more portable; CLI sugar can wrap it later.
A Vite plugin mode. Build the SSR bundle then auto-call
renderStatic as a post-build hook. Rejected for the same reason —
ties SSG to one build tool. Users on esbuild, tsup, or vanilla node --experimental-strip-types are equally entitled to SSG. The plugin
can be a thin wrapper later.
Include filesystem I/O inline. renderStatic({ outDir: 'dist' })
writes files itself. Rejected because runtime-specific imports
(node:fs/promises) pin the module to one platform. The onRoute
callback covers streaming-to-disk; the files map covers
collect-then-write. Both work everywhere.
Take an HTML template file path instead of a string. Lets users
keep their shell in index.html next to their dev-mode template.
Rejected for the same I/O reason — a one-line
readFile('index.html') in the caller is sufficient and stays
runtime-agnostic.
Use a streaming ReadableStream<{path, html}> return. Matches
the per-route emit model of renderToStream. Rejected: SSG is a
build-time batch operation, not a request response. Map plus the
onRoute callback covers the same cases without forcing the user
to consume an async iterable.