SSR + ISR
Server-rendered pages (renderMode: 'ssr') and incrementally-cached pages (renderMode: 'isr' + revalidate / staleWhileRevalidate / tenantAware) — the render modes that need a runtime, not a static CDN.
The server-rendered render modes in one app — a per-request renderMode: 'ssr' page (fresh on every visit, reads cookies/headers via useServerRequest()) plus two renderMode: 'isr' pages that cache the rendered HTML and revalidate it on a window (one tenant-aware, one with stale-while-revalidate). Unlike a static site these render on a runtime, so you serve them with voltro start, NOT a bare CDN. It's self-contained — the loaders compute their own data, so it boots with zero infra; swap a loader for ctx.query(...) to pull from your api. Template id: frontend-ssr.
Scaffold
voltro add-app web --template=frontend-ssr --to acme
voltro create-project acme --web=frontend-ssrWhat ships
apps/acme/web/ # dir named by the app, not the template
├── app.config.ts # type:web, theme:'system', defineEnv
├── package.json
├── tsconfig.json
├── README.md
└── src/
├── globals.css # self-contained plain CSS (no Tailwind / kit)
└── pages/
├── layout.tsx # nav + globals; <Link> client-nav
├── index.tsx # `/` — renderMode: 'ssr'
├── feed.tsx # `/feed` — renderMode: 'isr' + revalidate + tenantAware
└── feed-swr.tsx # `/feed-swr` — renderMode: 'isr' + staleWhileRevalidateNo rpc client, no apis: entry, no @voltro/ui-shadcn dependency. The app depends only on @voltro/web / @voltro/client / @voltro/env / @voltro/cli + React 19 — the three pages exist to demonstrate the SSR and ISR render modes, nothing else.
SSR vs ISR — both need a runtime
These render modes do NOT pre-render at build time the way static does — they produce HTML on a server, per request. voltro build won't bake them into flat files, and voltro start is what serves them. A pure CDN can't run a loader per request, so don't ship these there.
| Page | Mode | What it demonstrates |
|---|---|---|
/ |
ssr |
Fresh per request; useServerRequest() reads cookies + headers server-side |
/feed |
isr + revalidate + tenantAware |
Cached HTML, revalidated on a window, per-tenant cache key |
/feed-swr |
isr + staleWhileRevalidate |
Serve stale instantly, refresh in the background |
The full reference for these modes lives in the render modes guide.
renderMode = 'ssr' — fresh on every request
index.tsx exports renderMode = 'ssr'. Its loader runs SERVER-SIDE on EVERY request under voltro start — the timestamp and nonce change on each refresh, and the loader can read the incoming request to personalise the HTML before it's sent. There is no caching: the response carries x-voltro-rendered-by: ssr.
// src/pages/index.tsx
import type { ReactNode } from 'react'
import { useLoaderData, useServerRequest, type LoaderFn, type PageMeta } from '@voltro/web'
export const renderMode = 'ssr' as const
export const meta: PageMeta = {
title: '{{capProjectName}} — SSR (fresh per request)',
description: 'Server-rendered on every request.',
}
interface HomeData {
readonly renderedAt: string
readonly nonce: number
}
export const loader: LoaderFn<HomeData> = async () => ({
renderedAt: new Date().toISOString(),
nonce: Math.floor(Math.random() * 1_000_000),
})Reading the request with useServerRequest()
useServerRequest() reads the request's cookies + headers — on the server during SSR, and the same shape on the client. This is the primitive for cookie-backed preferences (locale, theme) WITHOUT a hydration flash — the server reads the cookie before render starts, so the SSR'd HTML is already correct:
export default function Home(): ReactNode {
const data = useLoaderData<HomeData>()
const req = useServerRequest()
const acceptLanguage = req?.headers['accept-language'] ?? 'unset'
const themeCookie = req?.cookies['voltro:theme'] ?? 'unset'
// … renders renderedAt, nonce, acceptLanguage, themeCookie …
}Use SSR for anything that varies per request: a logged-in dashboard, a page that reads the visitor's cookie/locale, or any data that must NOT be cached across requests.
renderMode = 'isr' — cache + revalidate + tenant-aware key
feed.tsx takes the SAME render path as SSR, but voltro start CACHES the HTML and serves the cached copy to every request within the revalidate window. After the window expires the next request re-renders and replaces the cache — cache hits return in well under a millisecond. tenantAware: true folds the x-tenant request header into the cache key, so tenant A's render is never served to tenant B:
// src/pages/feed.tsx
import type { ReactNode } from 'react'
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
export const renderMode = 'isr' as const
// Cache window — a number (seconds) OR a string like '1 hour' / '30 seconds'.
export const revalidate = '10 seconds'
// Tenant-aware caching: the cache key includes the `x-tenant` request header,
// so tenant A's render is never served to tenant B. Leave it off for pages
// that don't vary by tenant (they then share ONE cache entry).
export const tenantAware = true
export const meta: PageMeta = {
title: '{{capProjectName}} — ISR (cached + revalidated)',
description: 'Cached HTML, revalidated on a window.',
}
interface FeedData {
readonly renderedAt: string
readonly nonce: number
readonly tenant: string
}
export const loader: LoaderFn<FeedData> = async ({ headers }) => ({
renderedAt: new Date().toISOString(),
nonce: Math.floor(Math.random() * 1_000_000),
tenant: headers?.['x-tenant'] ?? 'anonymous',
})The loader's headers is populated server-side (lowercased keys) — that's how /feed reads x-tenant to label the row. Watch the response headers to see the cache work: x-voltro-cache: MISS on the first request (it rendered), then HIT within the window, then MISS again after it expires.
staleWhileRevalidate — serve stale, refresh in the background
feed-swr.tsx is the same ISR page plus a staleWhileRevalidate window. When revalidate expires, instead of blocking the next visitor on a fresh render, the cache serves the STALE HTML immediately AND kicks off a background re-render. The visitor never waits; the cache catches up out-of-band. Beyond revalidate + staleWhileRevalidate the next request blocks on a fresh render, like a cold MISS:
// src/pages/feed-swr.tsx
import type { ReactNode } from 'react'
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
export const renderMode = 'isr' as const
export const revalidate = '5 seconds'
// Within this window AFTER `revalidate` expires, requests get the STALE HTML
// instantly + a background refresh runs. Beyond (revalidate + swr) the next
// request blocks on a fresh render, like plain ISR.
export const staleWhileRevalidate = '30 seconds'
export const meta: PageMeta = {
title: '{{capProjectName}} — stale-while-revalidate',
description: 'Serve stale instantly, refresh in the background.',
}The response header tells you which path served the page: x-voltro-cache: MISS → HIT → STALE (background refresh) → MISS again after the SWR window.
Layout — nav + globals, full hydration
The root layout.tsx imports globals.css and renders a nav. SSR/ISR pages hydrate fully by default, so <Link> does client-side navigation after the first load:
// src/pages/layout.tsx
import type { ReactNode } from 'react'
import { Link } from '@voltro/web'
import '../globals.css'
export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
return (
<>
<nav className="top">
<Link to="/">/ (ssr)</Link>
<Link to="/feed">/feed (isr)</Link>
<Link to="/feed-swr">/feed-swr (isr + swr)</Link>
</nav>
<main>{children}</main>
</>
)
}globals.css is hand-written plain CSS with light/dark custom properties — no @import "tailwindcss", no @source glob, no @voltro/ui-shadcn. The dark variant keys off :root.dark, which the framework's pre-paint theme script toggles (driven by theme: 'system' in app.config.ts).
Config — self-contained, zero infra
app.config.ts is the minimal web shape: type: 'web', theme: 'system', and a single public env var. There's NO apis: entry — the loaders compute their own data, so the app boots with zero backend:
// app.config.ts
import { defineEnv, envVar } from '@voltro/env'
export const env = defineEnv({
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
})
export default {
type: 'web' as const,
name: '{{capProjectName}}{{capAppName}}',
port: {{port}},
theme: 'system' as const,
env,
}Pulling fresh data from your api
To render from YOUR reactive backend instead of computing the data in the loader, declare an apis entry in app.config.ts and call ctx.query in an ssr/isr loader. query is present ONLY server-side (ssr/isr); it invokes the api's rpc directly over POST /rpc, forwarding the session cookie so the SAME Subject + tenant resolve as the WS path:
// app.config.ts: apis: { app: { package: '@{{projectName}}/api' } }
export const loader: LoaderFn<Data> = async ({ query }) => ({
post: await query!('posts.get', { id: '…' }), // `query` is server-only
})In the browser, fetch live data with useSubscription in the component — the loader feeds the SSR first paint + the document <title>, the subscription keeps it live.
Build + serve
voltro build . # production build + pre-built SSR bundle
voltro start . # production runtime — serves SSR/ISR on demand, runs the cacheThe ISR cache only runs under voltro start. Watch the response headers there:
curl -i http://localhost:5190/ | grep x-voltro # x-voltro-rendered-by: ssr
curl -i http://localhost:5190/feed | grep x-voltro # x-voltro-cache: MISS, then HITWhen to use frontend-ssr vs. the other web templates
| You want… | Pick |
|---|---|
| Per-request HTML (cookies, auth) and/or cached-but-fresh content | frontend-ssr |
| A heavily interactive standalone tool whose state lives in the browser | frontend-spa |
| A blank React shell to bring your own brand | frontend-blank |
| A marketing landing surface | frontend-landing |
| A documentation site | frontend-docs |
Reach for frontend-ssr only when a page's HTML must differ per visitor (ssr) or must be cached-but-fresh (isr). For pages that are identical for everyone and stable for a whole deploy, prefer renderMode: 'static' — see the render modes guide.
Pairs well with
- Any api template when you grow a backend — add an
apis:entry inapp.config.tsand callctx.query(...)in the ssr/isr loaders to render from your reactive data. - Self-hosting on
voltro start/ a container — SSR/ISR need a runtime; only thestaticpages go to a CDN.
Anti-patterns
- Shipping SSR/ISR pages to a pure CDN. They need a runtime to render per request —
voltro static deploywill (correctly) refuse them. Serve them onvoltro start/ a container, and push only yourstaticpages to a CDN. - Reaching for
useServerRequest()AFTER a user interaction. It's for reading the request DURING the server render. To react to a button click that writes a cookie, use a regular event handler +document.cookie— SSR doesn't matter there. - Using
renderMode: 'ssr'for a page that's the same for everyone. SSR re-renders on every request with no caching. If the HTML doesn't vary per visitor, that'srenderMode: 'static'(identical for a whole deploy) orrenderMode: 'isr'(cached + revalidated) — SSR there is wasted work on every hit. - Setting
scope-less /tenantAware: falseon a per-tenant ISR page. WithouttenantAware: truean ISR page shares ONE cache entry across tenants — tenant A's render would be served to tenant B. Turn it on for anything that varies byx-tenant; leave it off only for pages that are genuinely the same for every tenant. - Calling
query!(...)without guarding for the client.ctx.queryisundefinedfor client-side loader invocations — only rely on it underrenderMode: 'ssr' | 'isr', and useuseSubscriptionin the component for live browser data.