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 const

At voltro build:

  1. The framework runs the page's render once (with useServerRequest() returning null).
  2. The output HTML lands at dist/about/index.html.
  3. voltro start serves 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 const

On every request:

  1. voltro start matches the URL → finds your page.
  2. Calls the loader (if any) with the request's params + headers + cookies.
  3. Renders the React tree to HTML.
  4. 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 out async at the end of the shell and hydration starts immediately.
  • isr and static are 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 exports ppr = true (partial prerendering, below) caches the shell and appends its deferred holes per request. On static it stays an error even with ppr — 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 this

A 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 bg

Cache backends:

  • memory (default) — in-process, doesn't survive restarts.
  • postgresSSR_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 but voltro: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-tenant and accept-language stay readable — they are cache-key inputs.
  • A hole is an async function. 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. ppr therefore requires interactive: '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, cacheInvalidatesOn and 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 = true

The 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 watch

The 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 tag

Tags 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_notify on the same LISTEN connection the CDC invalidator already holds — a postgres deployment needs no broker.
  • a broker: set BROADCAST_URL (redis:// or nats://) on both the api and the web deployment. This is the path for non-postgres dialects. On a broker shared by several projects, also set VOLTRO_BROADCAST_NAMESPACE on 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 isr routes. revalidatePath against a static route logs a named error on the web process — static HTML is a build artifact voltro start never 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 const

A 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 (or isr/ssr behind voltro 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 build only prerenders a shell whose chain has no layout loader (see above), and defer() 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 spa page'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 an ssr page: 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:

  1. Pre-rendered HTML exists at dist/foo/index.html? Serve it. (static + isr-already-cached.)
  2. No pre-render, page is ssr? Render fresh, serve.
  3. No pre-render, page is isr? Cache lookup → MISS → render → store → serve.
  4. No pre-render, page is spa with a layout? Render the SSR layout shell (layouts + an empty page slot) on demand; the client mounts the page into the slot.
  5. No pre-render, page is spa with no layout, or static? 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. renderMode is a closed set — 'static' | 'spa' | 'ssr' | 'isr'. Anything else ('client', 'csr', a typo) fails the build and voltro dev at 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 renderMode on a layout.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 renderMode per request. It's a static module export — one value per build.
  • Assuming ssr is client-only in dev. It is not: voltro dev runs the same SSR path voltro start does, streaming included, so cookie-driven gates and useServerRequest() behave the same in both. What dev does NOT do is pre-render static pages — those fall through to the SPA shell.
  • isr with cacheInvalidatesOn against memory cache. Memory cache is per-process; CDC events fire across processes. Use SSR_CACHE=postgres.
  • 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)