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.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].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.
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. That is also whydefer()is an error on those modes.
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.
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.
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 |
|---|---|
'none' |
Nothing — pure HTML. |
'islands' |
Only *.island.tsx files. |
'full' (default) |
Whole page. |
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 — pages that ship 0 JS except for explicit islands