Render modes
static (SSG) vs spa vs ssr vs isr — when each runs, what it caches, and how to pick.
Every page declares a renderMode. The mode controls when the HTML is produced — at build time, on every request, build-once-revalidate-occasionally, or not on the server at all.
export const renderMode = 'static' as const // 'static' | 'spa' | 'ssr' | 'isr'The four modes
| Mode | When HTML is produced | Cached? | Best for |
|---|---|---|---|
static (default) |
voltro build time |
Forever | Marketing pages, docs, anything that doesn't change per-request |
spa |
Never for the page itself (its layout chain may still be server-rendered) | — | Reactive dashboards whose state lives in the browser |
ssr |
Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
isr |
First request after build, then on revalidate | Per-key in-memory or Postgres | News feeds, listings, dashboards that change but not per-user |
Those four are the complete set. An unrecognised value is a hard error naming the page — see What doesn't work.
static (SSG)
// src/pages/about/page.tsx
export const renderMode = 'static' as constAt voltro build:
- The framework runs the page's render once (with
useServerRequest()returningnull). - The output HTML lands at
dist/about/index.html. voltro startserves the file directly — no React runs on the server.
For dynamic patterns, export getStaticPaths to enumerate every URL to pre-render:
// src/pages/blog/[slug]/page.tsx
export const renderMode = 'static' as const
export const getStaticPaths = async () => [
{ params: { slug: 'first-post' } },
{ params: { slug: 'second-post' } },
]One HTML file per entry lands in dist/blog/first-post/index.html etc.
Pages that don't enumerate (dynamic without getStaticPaths) fall through to the SPA shell — the client-side router takes over.
ssr
export const renderMode = 'ssr' as constOn every request:
voltro startmatches the URL → finds your page.- Calls the loader (if any) with the request's params + headers + cookies.
- Renders the React tree to HTML.
- Returns it.
Use SSR for:
- Authenticated pages that read the session cookie via
useServerRequest(). - Personalised content — recommendations, "your" anything.
- Search result pages — the query string changes per request.
What the server render sees
An SSR render is given the request, not a guess at it: the matched pathname
and params, the request's cookies and headers (useServerRequest()), and the
query string. useSearchParams() is the supported reader and works on both
sides — on the server it reads the request URL, on the client
window.location.search — so a page keyed off ?tab=… renders the same markup
in both places.
That last part is worth stating because it is the thing a mismatch is made of. Anything the server renders from a value the client computes differently hydrates with a warning, even when the DOM happens to agree; reach for the hook rather than reading the router context directly.
Cost: every request triggers a fresh render. For very high-traffic pages, prefer ISR.
Streaming SSR
An ssr page is streamed, on both boot paths (voltro dev and voltro start). The server sends the <head> and the page shell as soon as they are
rendered, then flushes each <Suspense> boundary as its data settles, inside
the same response.
For a page with no deferred data this is a time-to-first-byte win and nothing
else — React's render loop still runs to completion in one pass, so a slow
render is still a slow render. The lever that matters is defer(): it puts a
real <Suspense> boundary in the tree, which is what lets the server return to
the event loop while a slow value is still pending. See
Deferring slow data.
Measured against a real server rendering a page with one 400ms deferred field: first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every 10ms for the duration of that request was served 30 times with a 3ms median and a 7ms maximum. The request does not occupy the event loop while it waits.
Two consequences worth knowing:
- The entry script is emitted as a bootstrap module. A plain
<script type="module">is deferred until the document finishes parsing, which on a streamed response is after the last deferred boundary — the framework hands the URL to React instead, so it goes outasyncat the end of the shell and hydration starts immediately. israndstaticare still buffered, because both produce a stored artefact rather than a response.defer()is an error on those modes — with ONE exception: an isr page that exportsppr = true(partial prerendering, below) caches the shell and appends its deferred holes per request. Onstaticit stays an error even withppr— a static file is served by any dumb file host, which cannot append anything.
Apps do not call the renderer directly. If you are building your own server on
top of @voltro/web/ssr, renderPageToStream is the entry point — it takes the
same options as renderPageToHtml plus bootstrapModules and the stream
callbacks, resolves meta synchronously so the <head> can go out first, and
returns a Node pipeable stream.
isr (incremental static regeneration)
export const renderMode = 'isr' as const
export const revalidate = '60 seconds' // re-render when older than thisA bare number is read as seconds (Next.js compat) — revalidate = 60 means 60 seconds, NOT milliseconds. Use the string form ('60 seconds', '5 minutes', '1 hour') for clarity.
Behaviour:
| Request | Action |
|---|---|
| First | MISS — render, store in cache, serve. |
| Subsequent (cache fresh) | HIT — serve from cache. |
| After revalidate window | MISS — re-render, store, serve. |
With staleWhileRevalidate |
STALE — serve cached HTML immediately, kick off background refresh. |
export const renderMode = 'isr' as const
export const revalidate = '60 seconds'
export const staleWhileRevalidate = '60 seconds' // serve stale while refreshing in bgCache backends:
memory(default) — in-process, doesn't survive restarts.postgres—SSR_CACHE=postgres. Survives restarts, shared across api instances.
An isr page that also declares a searchParams schema is refused at boot — the cache is keyed by path (plus tenant + locale), not by query, so the first query's variant would be served for every other query; use ssr, or drop the export and read the query client-side only.
isr renders are anonymous
An isr render is a shared render: the HTML it produces is cached and served
to every visitor inside the revalidate window. The framework therefore strips
credential material before the render runs — the cookie jar (except the
voltro:locale cookie), the authorization header, and every x-voltro-*
header never reach an isr page's loaders, ctx.query, or useServerRequest().
x-tenant and accept-language survive, because the cache key (tenant + locale)
is derived from them.
Concretely: a loader on an isr page that reads subject-scoped data gets the
anonymous answer — the same one every visitor will see — instead of caching
the first visitor's data for everyone. This applies identically under
voltro dev and voltro start, so a page cannot look personalised in dev and
silently serve shared HTML in production. A page whose loader needs the signed-in
subject belongs on renderMode: 'ssr'.
Partial prerendering (ppr) — cached shell + per-request holes
An isr page can combine a cached, anonymous shell with per-request dynamic holes: the shell comes straight out of the cache (or the miss render), and the deferred fields stream in behind it on the SAME response — personalised, never cached.
export const renderMode = 'isr' as const
export const revalidate = 60
export const ppr = true
export const loader = ({ headers }) => defer(
{ title: 'Dashboard' }, // SHELL — cached, anonymous
{ greeting: personalGreeting(headers) }, // HOLE — per request, never cached
)How it works, and what each half may do:
- The shell render is fail-closed, not merely stripped. Its loader context
and
useServerRequest()snapshot THROW by name when a credential is read (cookie,authorization,x-voltro-*headers; any cookie butvoltro:locale): the first request answers with an error naming the read and the fix, instead of baking silently-empty subject data into an artefact served to everyone.x-tenantandaccept-languagestay readable — they are cache-key inputs. - A hole is an
asyncfunction. Its credential reads happen inside the promise: on the shell pass they reject harmlessly into the<Await>fallback; on the per-request hole pass they see the real request. A credential read in the EAGER half (or synchronously while building the hole promise) is the named error above — that is the fail-closed contract. - Holes reveal through hydration. The shell carries the
<Await>fallbacks, the registry bootstrap and the deferred-id payload; each hole value is appended as an inline settle script the moment its promise resolves, and the hydrated<Await>renders it.pprtherefore requiresinteractive: 'full'—'none'ships no JS to reveal anything and'islands'never hydrates the page root; both are refused by name. - The eager half runs twice per request (shell render on a cache miss, hole pass always). That is the cost model on purpose: eager data is the cheap, cacheable half; per-subject work belongs in the holes.
- Client-side navigation to a ppr page runs the loader in the browser —
holes resolve through the client defer path, same
<Await>markup. - Invalidation is the shell's:
revalidate,cacheInvalidatesOnand on-demand revalidation purge the SHELL entry; holes are never cached, so there is nothing to invalidate. - Layout loaders cannot defer on a ppr page (v1): holes live in the page loader; a deferring layout is refused by name.
- CSP nonces are refused on ppr exactly as on isr — the cached shell
carries inline registry scripts that cannot be per-request-nonce'd. Use
renderMode: 'ssr'for nonce'd pages, or a hash-based policy. - Without JavaScript (a text crawler, JS disabled) the hole fallbacks stay visible — the shell is complete, correct HTML; only the holes remain in their pending state. There is deliberately NO "buffer fully for crawlers" mode: user-agent sniffing serves different documents to crawlers and users, which is the cloaking failure class.
- A client that disconnects mid-response simply stops receiving settle scripts; nothing corrupts, the loader's own work completes server-side.
The response carries x-voltro-ppr: shell+holes next to the usual
x-voltro-cache headers, and the server counts shell serves, hole passes and
hole latency (see Observability).
Tenant-aware ISR
For multi-tenant ISR (each tenant gets its own cache entry):
export const renderMode = 'isr' as const
export const revalidate = '60 seconds'
export const tenantAware = trueThe cache key becomes ${pathname}|tenant=${tenant}, where tenant is the request's x-tenant header (falling back to anonymous). Tenant A's cached HTML never serves to tenant B.
CDC-invalidated ISR
When a specific DB write should invalidate the cache (instead of waiting for the revalidate window):
export const renderMode = 'isr' as const
export const cacheInvalidatesOn = ['posts', 'comments'] // tables to watchThe framework reads Postgres logical replication; writes to posts or comments invalidate every cached HTML for this query. New requests rebuild the page from the current data.
Requires SSR_CACHE=postgres and a wal_level=logical Postgres.
On-demand revalidation
The third invalidation axis, next to time (revalidate) and CDC
(cacheInvalidatesOn): server code in the api process drops ISR cache entries
imperatively, on every web replica — including on dialects that have no
CDC at all (sqlite, mysql, memory), which is the case this exists for.
import { revalidatePath, revalidateTable, revalidateTag } from '@voltro/runtime'
// inside a mutation / action / webhook receiver / REST route handler:
await revalidateTable('posts') // drop every route whose cacheInvalidatesOn lists 'posts'
await revalidatePath('/blog/[slug]') // drop every cached instance of the route
await revalidatePath('/pricing') // drop one concrete path (all tenant+locale variants)
await revalidatePath('/pricing', { tenant: 'acme' }) // …one tenant's variants only
await revalidateTag('pricing') // drop every route whose cacheInvalidatesOn lists the tagTags are tables that never were one. cacheInvalidatesOn accepts free
strings, so one mechanism covers both: declare cacheInvalidatesOn: ['posts', 'pricing'] on any number of routes and revalidateTag('pricing')
drops them all — the revalidateTag thinking Next.js users bring works
unchanged.
How it travels. The api process publishes; every voltro start replica
subscribes. Two transports, either or both:
- postgres: a
pg_notifyon the same LISTEN connection the CDC invalidator already holds — a postgres deployment needs no broker. - a broker: set
BROADCAST_URL(redis://ornats://) on both the api and the web deployment. This is the path for non-postgres dialects. On a broker shared by several projects, also setVOLTRO_BROADCAST_NAMESPACEon both sides — the channel is namespaced by that variable (the api's and the web app's names differ, so a name-derived namespace can't pair them).
A web process with ISR routes and neither transport warns at boot
(NO revalidation transport) — calls then change nothing and cached pages
live out their own revalidate window. Under voltro dev there is no ISR
cache; the calls are debug-logged no-ops.
Three edges, all deliberate:
- Only
isrroutes.revalidatePathagainst astaticroute logs a named error on the web process — static HTML is a build artifactvoltro startnever re-renders; rebuild to change it. (The transport is fire-and-forget, so the error surfaces in the web replica's log, not at the call site.) - Purge-during-render is guarded. A background SWR refresh (or miss fill) that started before the purge landed is discarded instead of writing the pre-purge page back with a full TTL — a per-key generation counter, on both cache backends.
- On postgres you don't need this for the plain publish case — a route
declaring
cacheInvalidatesOn: ['<table>']is already dropped by CDC when the table changes. Reach for the imperative API for non-postgres dialects, pattern purges of routes whose loaders read data indirectly, and tag fanout.
spa (client-only, with an optional SSR layout shell)
export const renderMode = 'spa' as constA spa page renders entirely in the browser — the page itself is never server-rendered. Reach for it when a page genuinely needs a fresh client render every load (most reactive dashboards) and doesn't need its own first-paint HTML or SEO.
If the page's route has a layout, that layout is still server-rendered. The server renders the layout chain — running its layout loaders — around an empty page slot (<div data-voltro-page-slot>), inlines the layout data, and marks the page client-only. The browser hydrates that shell and mounts the page into the slot after hydration. So the shell (nav, sidebar, auth gate) gets an instant first paint while the page stays client-only. The page's own loader still runs in the browser.
Because a layout now runs on the server for spa routes too, a layout used only by spa pages must be SSR-safe — no unguarded window / document in its render or its loader. Layouts shared with any static / ssr / isr page already render server-side (and static is the default), so they are unaffected. A spa page with no layout is a pure client mount, unchanged.
voltro build prerenders that shell to a file — but only when no layout in the page's chain exports a loader. A layout loader may resolve per-visitor data (the signed-in user, a tenant), and freezing one render of it into a static file would serve the first visitor's data to everyone. So a chain with any layout loader is left to voltro start, which runs the loader per request; the build logs which route it skipped and why. A loader-less chain is request-independent by construction and is written to dist/<route>/index.html, so a static host paints the layout immediately instead of an empty #root.
On a static host, that file is also the SPA fallback. A static host answers every URL it has no file for with index.html — so once your ROOT route is prerendered, a deep link to /reports is served the root's document. The framework handles this: the inlined hydration payload records the pathname it was rendered for, and the client refuses to adopt markup that belongs to another route, falling back to a normal client render instead. Without that check React would hydrate the root's layout while rendering /reports, report a hydration mismatch, and silently re-render the whole tree.
Nothing to configure. Two things follow from it, though:
- Deep links into a static deployment are client-rendered, not hydrated. The visitor sees the app; they don't get the prerendered paint. If that matters for a route, give it
static(orisr/ssrbehindvoltro start) so it has a file of its own. - The pathname is compared after normalising a trailing slash, a trailing
/index.html, and percent-encoding — the shapes a static host varies on. Your own routes are unaffected.
A layout in that chain may use defer(). The shell then STREAMS: the layout chain and the empty page slot flush immediately, and the deferred layout value arrives afterwards behind its <Await> boundary — the same mechanism an ssr page gets, applied to the shell. So a sidebar whose nav counts take 300ms no longer holds back the first paint of the rest of the shell. Nothing about the hydration seam changes: the first flush still carries the empty page slot, and the page still mounts into it after hydration.
What still cannot defer on this path:
- A prerendered shell.
voltro buildonly prerenders a shell whose chain has no layout loader (see above), anddefer()can only come from a loader — so the two never meet. If they did, the build would refuse by name rather than freeze the<Await>fallback into the file. - The page's own loader. A
spapage's loader runs in the browser, so there is no server render to stream into. Use<Await>on a client promise instead, or move the data into a layout loader. interactive: 'none'/'islands', for the same reason as on anssrpage: revealing a streamed boundary needs React's inline reveal scripts, and neither mode ever hydrates the root.
Picking between them
| You have… | Use |
|---|---|
| A truly static page (marketing copy) | static |
| A list of known pre-publishable URLs (blog posts) | static + getStaticPaths |
| A page that changes per user (dashboard, account) | ssr |
| A search results page (URL query → result) | ssr |
| A blog index that changes when posts are added | isr + cacheInvalidatesOn: ['posts'] |
A multi-tenant marketing site (acme.com/[tenant]/pricing) |
isr + tenantAware: true |
| A status page with 30s-stale acceptable | isr + revalidate = '30 seconds' |
What about interactive?
interactive is orthogonal to renderMode — it controls how much JS runs in the browser. See Islands.
interactive |
What's hydrated | JS shipped |
|---|---|---|
'none' |
Nothing — pure HTML. | None — the script tags are stripped. |
'islands' |
Only *.island.tsx files. |
The full app bundle, same as 'full'. |
'full' (default) |
Whole page. | The full app bundle. |
The third column is the one people get wrong: 'islands' buys back hydration CPU, not download. Only 'none' removes bytes. See Islands for the measured numbers.
Combinations:
renderMode × interactive |
When |
|---|---|
static + none |
Marketing pages, blog posts. Zero JS. |
static + full |
SSG with full client-side nav. Docs sites. |
ssr + full |
Dashboards. The most "Next.js-like" mode. |
isr + islands |
News feeds with a "like" button island. |
What gets served when
A request to /foo:
- Pre-rendered HTML exists at
dist/foo/index.html? Serve it. (static + isr-already-cached.) - No pre-render, page is
ssr? Render fresh, serve. - No pre-render, page is
isr? Cache lookup → MISS → render → store → serve. - No pre-render, page is
spawith a layout? Render the SSR layout shell (layouts + an empty page slot) on demand; the client mounts the page into the slot. - No pre-render, page is
spawith no layout, orstatic? Serve the SPA shell — the client router takes over.
That last case is how dynamic static routes work in dev / when getStaticPaths didn't include the URL.
What doesn't work
- Any value outside the four modes.
renderModeis a closed set —'static' | 'spa' | 'ssr' | 'isr'. Anything else ('client','csr', a typo) fails the build andvoltro devat codegen, naming the page, the value and the valid set. There are no aliases: a page that renders only in the browser is'spa'. - Declaring
renderModeon alayout.tsx/error.tsx/loading.tsx. The mode is a property of the PAGE; the framework never reads one off a special file. Whether a layout renders on the server follows from the page's mode. - Switching
renderModeper request. It's a static module export — one value per build. - Assuming
ssris client-only in dev. It is not:voltro devruns the same SSR pathvoltro startdoes, streaming included, so cookie-driven gates anduseServerRequest()behave the same in both. What dev does NOT do is pre-renderstaticpages — those fall through to the SPA shell. isrwithcacheInvalidatesOnagainst memory cache. Memory cache is per-process; CDC events fire across processes. UseSSR_CACHE=postgres.
Where to read next
- Loaders & meta — fetch data before render, inject
<head>tags - Islands — scope hydration to explicit islands (note: this reduces hydration work, not the JS payload —
'none'is the mode that removes bytes)