Static generation (SSG & ISR)
By default every page is rendered server-side on each request (dynamic SSR). For pages that don't change per request, you can opt into static generation: the HTML is rendered once, cached, and served from the cache. Two route fields turn it on, and they compose.
This is purely opt-in. A route that declares neither field renders exactly as it does today — fresh on every request, with the usual Cache-Control: no-cache.
Route configuration
Static generation is configured per route in backend/routes.json:
{
"routes": [
{ "path": "/", "template": "index" },
{ "path": "/about", "template": "about", "prerender": true },
{ "path": "/pricing", "template": "pricing", "revalidate": 300 },
{ "path": "/blog", "template": "blog", "prerender": true, "revalidate": 600 }
]
}
| Field | Type | Meaning | |-------|------|---------| | prerender | bool | Pure SSG. Render once, cache, serve the cached HTML thereafter. Warmed eagerly at startup. | | revalidate | integer (seconds) | ISR. Serve cached HTML; once it is older than the TTL, the next request regenerates it in the background. |
/about— pure SSG: rendered at startup, served from cache, never re-rendered until the next restart./pricing— ISR (lazy): rendered on first request, then refreshed in the background once the cached copy is older than 300 s./blog— both: warmed at startup (so the first request is instant) and kept current by the 600 s TTL.
revalidate must be a non-negative whole number; negative or fractional values are ignored (the route stays pure-SSG if prerender is set, otherwise dynamic).
Caching model
Generated HTML is persisted in a dedicated B+tree at data/ssg.db, keyed by the route path. Each entry stores the rendered bytes alongside the wall-clock second it was generated. Because it's the same crash-safe storage engine the rest of the framework uses, the cache file survives process restarts.
Only a successful 200 render is ever cached. If a template errors (or otherwise doesn't return 200), the live response is returned uncached, so a broken page is never frozen into the cache.
ISR semantics: stale-while-revalidate
The implemented ISR model is stale-while-revalidate:
- A request for an ISR route whose cache entry is fresh (
age < revalidate)
gets the cached bytes immediately.
- A request whose entry is stale (
age >= revalidate) also gets the
cached (stale) bytes immediately — there is no request-time latency penalty — and the server kicks off a background re-render on a separate thread.
- The freshly rendered HTML replaces the cache entry with a new timestamp.
Subsequent requests serve the updated copy.
Concurrent stale requests for the same path coalesce: only one background regeneration runs at a time per route (no thundering herd). If a background re-render fails, the previous (stale) entry is left in place rather than dropped.
This means an ISR page can serve one stale response right at the TTL boundary before the refreshed copy lands — the standard ISR trade-off in exchange for never blocking a request on rendering.
Regeneration & invalidation triggers
There are two triggers, and that's the whole story:
- Restart (rebuild). On
serve/devstartup the SSG cache is cleared and
every prerender route is re-rendered and stored. This is the invalidation mechanism: change a template or its data, restart, and the prerendered pages rebuild from scratch. Routes removed from routes.json don't leave stale HTML behind, because the cache is cleared first.
- TTL (ISR). Routes with
revalidateregenerate themselves on the next
request after their TTL elapses, as described above — no restart needed.
A pure-prerender route (no revalidate) is cached indefinitely between restarts: it only changes when the binary regenerates it at the next startup.
Cache-Control headers
SSG responses set a browser cache header scoped to the route; the existing no-cache-on-dynamic-HTML behaviour is unchanged.
| Route kind | Cache-Control | |------------|-----------------| | Dynamic (no SSG fields) | no-cache (unchanged) | | Pure prerender | no-cache — the browser revalidates, but the server answers from cache | | revalidate: N (ISR) | public, max-age=N — matches the regeneration window |
Pure prerender keeps no-cache so a browser never silently holds a copy the server has since rebuilt; the server-side cache still does the heavy lifting.
Limitations
- Pattern routes aren't startup-warmed. A
:param/*wildcardroute has no
single concrete URL, so it can't be prerendered at startup. (An ISR pattern route still works lazily: each distinct request path gets its own cache entry, keyed by the full path.)
- No per-entry manual purge. Invalidation is whole-cache (restart) or
time-based (ISR TTL). There is no HTTP endpoint to evict a single path.
- Time-based, second granularity. Freshness uses whole seconds of wall-clock
time; a backwards clock jump can momentarily extend perceived freshness.
- One stale response at the boundary. By design, the request that trips the
TTL receives the stale copy while the refresh happens in the background.