Loaders & meta
Server-side data fetch via `loader`, page-level `<head>` tags via `meta`, and how the build pipeline runs both.
A loader is the page's data hook. It runs before the React render (during SSR, during SSG, or per-request for ISR/SSR), and its result lands in useLoaderData<T>(). Meta is a sibling export that produces <title> + <meta> tags.
It runs on the server AND again in the browser. A loader is not a server-only hook: it runs during SSR for the first paint, and runs AGAIN, in the browser, on every in-app navigation to the route. Same function, different environment — so anything server-only in it must be guarded with
ctx.isServer.This is the single most expensive thing to learn late, because a client-only failure is invisible to every probe that does not NAVIGATE: a fresh page load, a
curl, any SSR check all take the server path and pass. Only clicking a link inside the running app reaches the other one.
Both are static module exports — the framework discovers them, the build pipeline runs them.
A loader
// src/pages/notes/[id]/page.tsx
import { useLoaderData } from '@voltro/web'
interface Note {
readonly id: string
readonly title: string
readonly body: string
}
export const renderMode = 'ssr' as const
export const loader = async ({ params, headers }: {
params: { id: string }
headers: Readonly<Record<string, string>>
}): Promise<Note> => {
// Runs during SSR *and* again in the browser on in-app navigation —
// guard anything server-only with `ctx.isServer`.
const res = await fetch(`${INTERNAL_API}/notes/${params.id}`, {
headers: { cookie: headers.cookie ?? '' },
})
if (!res.ok) throw new Error(`note ${params.id}: ${res.status}`)
return await res.json()
}
export default function NotePage(): ReactNode {
const note = useLoaderData<Note>()
return (
<article>
<h1>{note.title}</h1>
<p>{note.body}</p>
</article>
)
}useLoaderData<T>() returns the loader's resolved value, typed via the generic.
On a page that declares a loader, the value is always there. The router
never renders such a page without its data: a settled loader commits its data
and the displayed route together, a pending one shows the Pending skeleton
(or keeps the previous page), and one that threw renders the error subtree. You
do not need a guard, and adding one only hides a real mistake behind a ?..
Calling it where no loader exists is an error, and it says so. A level with
no loader export throws:
useLoaderData() was called at a level that declares no `loader`. Export `loader`
from this page/layout, or — if this component is shared between routes that have
one and routes that do not — read it with `useOptionalLoaderData()` …Note what is NOT an absence: an empty result. A loader returning
{ items: [] } returns exactly that. undefined never means "the query found
nothing" — it means "there is no loader at this level".
useOptionalLoaderData() — for a component on both kinds of route
One case needs it: a component genuinely mounted both under routes that declare
a loader and routes that do not. It returns undefined instead of throwing.
const data = useOptionalLoaderData<Data>()
const project = data?.projectA loader that legitimately resolves to undefined is not an error — both hooks
hand that undefined back. Only the missing loader throws.
When loaders run
| renderMode | When loader runs |
|---|---|
static |
At voltro build time, once. Result baked into HTML. |
ssr |
Every request. |
isr |
On cache MISS (re-runs when cache stale). |
For static pages with getStaticPaths, the loader runs once per enumerated path.
Server-rendered data reaches the first client render
For static, ssr and isr pages the framework inlines the loader's result
into the HTML document (a <script type="application/json" id="__voltro_state__"> tag) and the browser adopts it before React hydrates.
Two consequences worth designing around:
useLoaderData()returns real data on the very first client render. It is notundefineduntil an effect has run, so a page can dereference its loader data directly (data.title) without a guard, and a layout renders its loader's value identically on the server and on the client — no hydration mismatch, no flash of fallback content.- The loader does NOT re-run on that initial hydration. It already ran on
the server; running it again in the browser would just re-fetch what the page
is already showing. If you need work to happen after mount (refreshing data,
a side effect), put it in an effect or use
useSubscription— do not rely on the loader firing a second time.
SSR first paint, then live — initialSnapshot
When a page wants the SSR-rendered data AND a live subscription, hand the loader's value to useSubscription as its initialSnapshot. The loader fetches the data on the server with ctx.query; the subscription shows that value at the first paint with loading: false — it is real server data, not a placeholder — then swaps to the live stream the instant its first snapshot arrives:
export const loader = async ({ query }) => query('employees.me', {})
export default function Profile() {
const seed = useLoaderData<Employee>()
// First paint shows the SSR value; the WS stream takes over seamlessly.
const { data } = useSubscription<Employee>('app', 'employees.me', {}, { initialSnapshot: seed })
return <ProfileCard employee={data} />
}Because the SSR markup and the hydration render read the same loader value, they match — no hydration flicker — and you don't hand-build a seed store to bridge the two. This is distinct from fallback, whose value never came from the server and so keeps loading: true; use exactly one of the two.
Seed whatever the screen checks FIRST, not whatever is most interesting. A component's server render stops at its OUTERMOST unsatisfied gate, so an unseeded subscription in an early branch hides every seeded one below it:
// Seeding `roadmap` changes nothing while this branch is the first one. if (availableYearsIdle || availableYearsLoading) return <Spinner /> return <Roadmap data={roadmap} />The symptom is a page that still server-renders a spinner after you seeded the data you care about. Walk the component's early returns from the top and seed each subscription they read, or move the gate below the render you want. The same applies to an auth gate: a
useSubscription-backedAuthenticationProviderhas no data during a server render, so every page under it renders its loading state until that subscription is seeded too.
Client-side navigation is unchanged: moving to another route runs that route's
loaders in the browser as usual. A spa page has no server render, so its
loader runs on the client on first mount.
Layout loaders are inlined the same way, keyed per layout, so each layout reads
its OWN data on the first render. This includes voltro build's static
prerender: it runs the page loader and every layout loader in the chain at
build time, so a CMS-backed nav or footer is baked into the prerendered file and
the layout loader does not re-run after hydration. Layout loaders see the same
build-time context the page loader does — params, pathname, signal — and
nothing request-shaped: there is no headers and no query at build time. A
layout loader that needs either belongs on an ssr page.
If a layout loader throws during the build, the page is still prerendered — with no layout data, and a warning in the build log. The layout then resolves its data on the client after mount, and the PAGE keeps its own inlined data for the whole of that window, so the no-guard promise above still holds: only the layout shows its no-data fallback until its loader settles.
Deferring slow data: defer() + <Await>
A loader blocks the whole response. One slow field therefore costs every byte
of the page — the user stares at nothing while a report query runs. defer()
splits the loader's result into data that blocks the shell and data that
streams in after it, behind a <Suspense> boundary the server flushes as
soon as the promise settles.
// src/pages/dashboard/page.tsx
import { Await, defer, useLoaderData } from '@voltro/web'
export const renderMode = 'ssr' as const // required — see below
export const loader = async ({ query }: { query?: <T>(tag: string, input?: Record<string, unknown>) => Promise<T> }) => defer(
// EAGER — awaited before the shell renders. Keep this fast.
{ user: await query?.<User>('users.me') },
// DEFERRED — NOT awaited. Each becomes a promise on useLoaderData().
{ report: query!<QuarterlyReport>('reports.quarterly') },
)
export default function Dashboard() {
const { user, report } = useLoaderData<Awaited<ReturnType<typeof loader>>>()
return (
<main>
<h1>Hello {user?.name}</h1>
<Await value={report} fallback={<ReportSkeleton />}>
{(report) => <ReportTable rows={report.rows} />}
</Await>
</main>
)
}What the browser sees: the full page with <ReportSkeleton /> in place,
immediately — then the real table, injected in a later chunk of the same
response. No second request, no client-side fetch, no loading spinner driven by
useEffect.
Two explicit buckets, not one object. defer(eager, deferred) takes them
separately rather than treating any promise-valued field as deferred. Deferral
is then something you wrote down, not something inferred from a value's runtime
shape — and useLoaderData() can type it: eager fields come back as values,
deferred fields as Promise<T>, so the compiler tells you which ones need an
<Await>.
<Await>
| Prop | Meaning |
|---|---|
value |
A deferred field off useLoaderData(). |
fallback |
Rendered until the value arrives. This is what ships in the streamed shell — keep it cheap and layout-stable. |
children |
(value) => ReactNode — rendered with the resolved value. |
errorFallback |
Rendered if the deferred promise rejects. Without it, a rejection renders nothing in that subtree. |
<Await> owns the <Suspense> boundary and the hydration handoff for the
streamed value. Do not hand-roll it with <Suspense> + use() — the server
and the client would then have to agree on a wire format that the framework
otherwise guarantees by construction.
A rejected deferred value never takes the page down: it renders
errorFallback in place, on the server and on the client alike.
defer() requires a streamed response and full interactivity
That means renderMode: 'ssr', or — for a layout loader — the SSR layout
shell of a renderMode: 'spa' page under it, which voltro dev and
voltro serve also stream (see
render modes).
Every other combination is a hard error at boot or build, naming the page —
because each one fails silently otherwise:
| Combination | Why it is rejected |
|---|---|
renderMode: 'static' |
The static prerender uses renderToString, which does not support Suspense. It emits an errored boundary and a "switched to client rendering" template with no warning — the artefact would ship a permanent fallback. |
renderMode: 'isr' |
ISR caches a completed HTML string. Filling it in would make defer() a silent no-op that still reads like it streams. |
interactive: 'none' |
Revealing a streamed boundary needs React's inline reveal scripts, and this mode ships no JS. The fallback would be permanent. |
interactive: 'islands' |
The page's React root never hydrates, so nothing consumes the streamed value. |
| A prerendered spa layout shell | voltro build writes it to a file, which has no "after". Unreachable in practice — the build only prerenders a shell whose chain has no layout loader — but refused by name if it ever is reached. |
In all of these the fix is the same: put the value in the eager bucket (or return it directly) and let the page render as it did before.
Layout loaders can defer too
A layout.tsx loader may return defer() under the same rules. Its deferred
fields are keyed per layout, so a layout reads its own promises via
useLoaderData() exactly as a page does.
This includes a layout that wraps a client-only (renderMode: 'spa') page.
The server renders that route as a layout shell — the layout chain around an
empty page slot — and a deferring layout makes that shell stream: chain and slot
first, the deferred layout value afterwards. The page still mounts on the client
after hydration, unchanged. What a spa page's own loader cannot do is defer:
it runs in the browser, so there is no server render to stream into.
Client-side navigation
On a client-side navigation there is no server render, so the loader runs in the
browser and its deferred fields are ordinary promises. <Await> renders the
fallback and swaps in the content when they settle — the same code, driven by
React alone.
Loader arguments
export const loader = async (ctx: {
readonly params: Readonly<Record<string, string>> // URL params from [name] segments
readonly pathname: string // matched path (no query string)
readonly isServer: boolean // true during SSR/SSG, false in the browser
readonly search: string // raw query string incl. `?`, or '' — filled on every path
readonly signal: AbortSignal // Aborts if the client disconnects mid-render
readonly headers?: Readonly<Record<string, string>> // Request headers (SSR/ISR only — empty for SSG/client)
// Call the backend rpc directly — present ONLY when the loader runs
// server-side (`voltro start` / `voltro dev` SSR); `undefined`
// client-side. Resolves a query's FIRST (initial) snapshot.
readonly query?: <T = unknown>(tag: string, input?: Record<string, unknown>) => Promise<T>
}) => Promise<unknown>Branch on isServer, not on what happens to be missing
isServer is the supported way to ask which invocation this is. The two things
that look like they answer the same question do not:
queryis absent in the browser, soif (ctx.query)appears to work. It branches on the ABSENCE OF A FUNCTION, which says nothing about why it is absent and breaks the moment anything else becomes conditional.headersis{}in the browser, notundefined— soif (ctx.headers)is TRUE on both paths. A deployment wrote exactly that check and it silently did nothing.
export const loader = async (ctx: LoaderContext) => {
if (ctx.isServer) seedStore(prefs, await ctx.query!('prefs.get'))
return null
}The loader context carries pathname and search, not a request object.
pathname is deliberately query-free — a loader keyed on ?tab=2 would cache badly. search carries the raw query string (with its leading ?, or ''), filled identically on client navigation, voltro dev SSR and voltro start SSR. Parse it with new URLSearchParams(ctx.search).
Reach for search when the loader makes a decision, not when it fetches data. The case it exists for is a redirect target that depends on a parameter:
import { RedirectError } from '@voltro/web'
export const loader = async (ctx) => {
const player = await ctx.query('players.byCode', { code: ctx.params['playerCode'] })
if (!player) {
// Preserve kiosk mode across the redirect — otherwise a kiosk terminal
// drops back to normal mode after every failed scan.
const mode = new URLSearchParams(ctx.search).get('mode')
throw new RedirectError(`/?error=${ctx.params['playerCode']}${mode ? `&mode=${mode}` : ''}`)
}
return { player }
}Do not reconstruct this from window.location.search: that exists only on the client-navigation path, so a fresh SSR request loses the value — which is the bug the field was added to remove.
Use signal for any fetch that could outlive the request — pass it to fetch(url, { signal }) so cancelled requests don't waste CPU.
Fetching backend data with ctx.query
Instead of hand-rolling a fetch(INTERNAL_API/...), a server-side loader can call the backend rpc directly through ctx.query — the same query tags the client subscribes to, resolved to their initial snapshot:
// src/pages/notes/[id]/page.tsx
import { useSubscription } from '@voltro/client'
import { useLoaderData, type PageMeta } from '@voltro/web'
interface Note { readonly id: string; readonly title: string; readonly body: string }
export const renderMode = 'ssr' as const
export const loader = async ({ params, query }: {
params: { id: string }
query?: <T>(tag: string, input?: Record<string, unknown>) => Promise<T>
}) => {
// `query` is undefined client-side — guard it. SSR forwards the
// request's cookie, so the api resolves the SAME Subject + tenant
// as the WebSocket path.
const note = query ? await query<Note>('notes.get', { id: params.id }) : undefined
return { note }
}
export const meta = ({ loaderData }: { loaderData: { note?: Note } }): PageMeta => ({
title: loaderData.note ? `${loaderData.note.title} — Notes` : 'Notes',
description: loaderData.note?.body.slice(0, 140) ?? '',
})
export default function NotePage() {
const { note: ssrNote } = useLoaderData<{ note?: Note }>()
// Live updates after hydration: useSubscription takes over from the
// SSR snapshot. The loader gave us first-paint HTML + correct meta;
// the subscription keeps it fresh.
const { data } = useSubscription<Note>('app', 'notes.get', { id: ssrNote?.id ?? '' }, { skip: !ssrNote })
const note = data ?? ssrNote
if (!note) return null
return <article><h1>{note.title}</h1><p>{note.body}</p></article>
}Two rules that fall out of this:
queryis server-only. It'sundefinedfor client-side loader invocations (SPA navigation re-runs the loader in the browser). Guard it (query ? … : undefined) and useuseSubscriptionin the component for the reactive, after-hydration path. The loader'squeryis for SSR first-paint +meta.- It forwards the request cookie. The HTTP rpc resolves the same Subject + tenant as the WebSocket connection would, so tenant-scoped queries return the right rows during SSR.
Under the hood, ctx.query is a one-shot POST /rpc call (see Wire protocol).
Authentication in loaders
Auth is resolved by the api, never the web app. Two rules follow:
- Strategies live on the
type:'api'app. Atype:'web'app has no auth middleware, soauth.strategiesin a webapp.config.tsdoes nothing. Configure your IdP (supabaseStrategy,workosStrategy, the built-in password strategy, …) on the api'sapp.config.ts. ctx.queryforwards the request's cookie automatically. A server-side loader'sctx.querysends the browser'sCookieheader on the one-shotPOST /rpc, so the api resolves the SAME Subject + tenant it would over the WebSocket. You never thread a token through by hand — a logged-in user's cookie-mode session (e.g.@supabase/ssr'ssb-<ref>-auth-token) is verified by the api's strategies, and tenant-scoped queries return that user's rows during SSR.
So a cookie-mode Supabase app configures supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' }) on the api; the web loader's ctx.query then authenticates for free. See Supabase Auth.
Renewing an EXPIRED session — middleware.ts
The rules above assume the cookie is still valid. When it is not — a token older
than the IdP's lifetime, which for a 1-hour token is practically every first
page view of the day — the api resolves the caller to anonymous and every loader
and preload on the page fails.
You cannot fix that in a loader. ctx.query and every preload entry are bound
from ONE cookie string before any loader runs, so a layout loader that
renews the session cannot reach them. middleware.ts at the web app root runs
earlier than both, says which routes it covers, and writes the rotated cookie
back so this render and the browser agree.
See Middleware for the full contract: match
(under / routes / except / assets), the one-middleware-per-route rule,
and what it deliberately cannot do.
Errors from loaders
If the loader throws, the framework:
- Catches the throw.
- Renders the page's
error.tsx(or the nearest ancestor's) with the error. - Serves the resulting HTML.
For 404s, throw a NotFoundError:
import { NotFoundError } from '@voltro/web'
export const loader = async ({ params, query }) => {
const note = query ? await query('notes.get', { id: params.id }) : undefined
if (!note) throw new NotFoundError(`note ${params.id}`)
return note
}The framework returns a 404 status + renders not-found.tsx for that subtree. The notFound() helper is throwing sugar for the same thing — const note = (await load()) ?? notFound('note ' + params.id) reads well when the not-found is inline.
Meta
import type { PageMeta } from '@voltro/web'
export const meta: PageMeta = {
title: 'Notes — Voltro',
description: 'All your notes, in one place.',
tags: [
{ property: 'og:title', content: 'Voltro Notes' },
{ property: 'og:description', content: 'All your notes, in one place.' },
{ property: 'og:image', content: '/og.svg' },
{ name: 'twitter:card', content: 'summary_large_image' },
],
}The framework injects these into the HTML's <head> at build / SSR time:
<head>
<title>Notes — Voltro</title>
<meta name="description" content="All your notes, in one place." />
<meta property="og:title" content="Voltro Notes" />
<meta property="og:image" content="/og.svg" />
…
</head>The default document title
A page's meta.title overrides the tab title on navigation. Before any page
sets one — the initial HTML shell, a route with no meta, an error page — the
browser tab shows the app's default title, set in app.config.ts:
export default {
type: 'web' as const,
name: 'AcmeDashboard', // internal identifier (package/port lookup) — PascalCase by convention
title: 'Acme', // human document title baked into the HTML shell
}title is the default <title>. It is distinct from name, the app's internal
identifier — leaking that PascalCase identifier into the tab reads as a dev
artefact. When title is unset the shell falls back to name, so set a real
product title on any app users actually see. Per-page meta.title still wins
wherever a page provides one.
Dynamic meta from params + loader data + locale
When the meta depends on the URL or on what the loader fetched, export meta as a function. It receives a single object { params, loaderData, locale } and runs at build / SSR time after the loader resolves:
export const meta = ({ params }: { params: { id: string } }): PageMeta => ({
title: `Note ${params.id} — Voltro`,
description: '…',
})Reading the loader's result lets the title/description reflect fetched fields — the canonical "page title is the note's title" case:
export const loader = async ({ params, query }) => ({
note: query ? await query('notes.get', { id: params.id }) : undefined,
})
export const meta = ({ loaderData }: { loaderData: { note?: { title: string; body: string } } }): PageMeta => ({
title: loaderData.note ? `${loaderData.note.title} — Voltro` : 'Voltro',
description: loaderData.note?.body.slice(0, 140) ?? '',
})The third context field — locale: string — is the active i18n locale for this render. For [locale]/… routes it carries the URL-prefix locale ('de' on /de/notes/42). For bare-path routes it carries the active locale from the framework's voltro:locale cookie when set — so cookie-based i18n works too (an authed dashboard with no [locale] URL still gets a translated <title> that tracks the language switch) — otherwise the app's defaultLocale. Use it to localise title / description / canonical / OG per locale at SSG time so search engines see translated head tags on every variant, and to give cookie-i18n pages a translated tab title:
import { getCatalog } from '../lib/locale'
import { seoAlternates } from '@voltro/web'
export const meta = ({ locale }: { locale: string }): PageMeta => {
const c = getCatalog(locale)
return {
title: c['seo.notes.title'] as string,
description: c['seo.notes.description'] as string,
// Canonical URL for THIS locale + a reciprocal `hreflang` alternate for
// every locale (incl. `x-default`), spread straight into the meta.
...seoAlternates({
siteUrl: 'https://notes.example.com',
path: '/notes',
locale,
locales: ['en', 'de'],
defaultLocale: 'en',
}),
}
}meta(ctx) runs once per (page × locale) at build time. The full per-locale SSG flow — [locale]/… mirror routes, the build-time <I18nProvider> wrap, and the dist layout — is documented in Internationalization → URL strategies.
loaderData is the PAGE loader's result. Because meta runs server-side after the loader, the SSR'd <head> is already correct on first paint — no client-side title patching, no flash.
Technical SEO: canonical, hreflang, sitemap & robots
The framework ships the cheap technical-SEO primitives so an indexable app gets them without app-level plumbing.
Canonical + hreflang helpers (@voltro/web) are pure functions you call from meta. They are browser-safe, so importing them into a *.page.tsx never drags a server module into the client bundle:
canonicalUrl(siteUrl, path)— one absolute canonical URL.seoAlternates({ siteUrl, path, locale, locales, defaultLocale })— returns{ canonical, links }wherecanonicalis this locale's URL andlinksis onerel="alternate"per locale (each an absolute URL, as Google requires) plushreflang="x-default". The alternate set is reciprocal across every locale — exactly what Google's hreflang rules want. The locale model is URL-PREFIX routing: the default locale on the bare path (/notes), other locales prefixed (/de/notes).
Keeping a page out of the index — set noIndex on its meta. It emits <meta name="robots" content="noindex, nofollow"> AND excludes the route from the generated sitemap.xml:
export const meta: PageMeta = { title: 'Checkout', noIndex: true }sitemap.xml + robots.txt are generated at voltro build from the prerendered routes. Turn them on with a seo.siteUrl in app.config.ts:
export default {
type: 'web' as const,
name: 'Notes',
locales: ['en', 'de'],
defaultLocale: 'en',
seo: {
siteUrl: 'https://notes.example.com',
disallow: ['/admin'], // extra robots Disallow prefixes (optional)
},
}sitemap.xmllists every prerendered route (minusnoIndexones). With 2+locales, each URL carries the fullxhtml:linkalternate set. Written only whenseo.siteUrlis set — absolute URLs are required.robots.txtis generated even withoutsiteUrl. Production allows all and advertises the sitemap;voltro dev— and any build withVOLTRO_SEO_NOINDEX=1(staging / preview deploys) — disallows everything, so a non-production surface never gets indexed by default.- A user-authored
public/sitemap.xml/public/robots.txtalways wins — the generator never overwrites one.
Examples
Authenticated dashboard with cookie-driven loader
// src/pages/dashboard/page.tsx
import { useLoaderData } from '@voltro/web'
export const renderMode = 'ssr' as const
export const loader = async ({ headers }) => {
const cookieHeader = headers.cookie ?? ''
const me = await fetch(`${INTERNAL_API}/auth/me`, { headers: { cookie: cookieHeader } })
if (!me.ok) throw new RedirectError('/login')
const user = await me.json()
return { user }
}
export default function Dashboard() {
const { user } = useLoaderData<{ user: User }>()
return <h1>Hi {user.name}</h1>
}RedirectError is the framework's way to issue a 303 from a loader. See Navigation for client-side analog.
SSG with per-post meta
getStaticPaths has no framework store — it reads its own content source (a CMS client, the filesystem, an API). The loader runs server-side and fetches via query (the backend rpc, resolved to its first snapshot):
// src/pages/blog/[slug]/page.tsx
import { listPostSlugs, type Post } from '../../content/posts'
export const renderMode = 'static' as const
export const getStaticPaths = async () => {
const slugs = await listPostSlugs() // your own content source — fs / CMS / API
return slugs.map((slug) => ({ params: { slug } }))
}
export const loader = async ({ params, query }) => {
// `query` is present only server-side (SSG build / SSR). Resolves the
// backend query's first snapshot.
return { post: await query!('posts.getBySlug', { slug: params.slug }) }
}
export const meta = ({ loaderData }: { loaderData: { post: Post } }): PageMeta => ({
title: `${loaderData.post.title} — Blog`,
description: loaderData.post.excerpt,
})Loaders are NOT React hooks
They're plain async functions. They can't call useSubscription, useState, etc. — they run server-side.
If you need a reactive query (live updates), use useSubscription in the component AFTER hydration; for the initial render's data, use the loader.
OG images from a template — ogImage
Declare the page's og:image as a satori JSX template and the framework
produces the PNG: at BUILD time for static pages (hashed into
dist/assets/og/, tags injected with the absolute seo.siteUrl), ON DEMAND
for ssr pages over a signed route with a cache.
export const ogImage = ({ params, loaderData, locale }: {
params: Record<string, string>
loaderData: unknown
locale: string
}) => ({
type: 'div',
props: {
style: {
display: 'flex', width: '100%', height: '100%',
background: '#0b1220', color: '#fff', fontSize: 72, fontFamily: 'Inter',
alignItems: 'center', justifyContent: 'center',
},
children: `My post ${params['slug'] ?? ''} (${locale})`,
},
})meta wiring is automatic: og:image, twitter:image and twitter:card
land in the head — unless your meta already sets og:image, which then
wins (no duplicate tag for crawlers to pick at random).
Preconditions, decided rather than improvised:
- A declared font is REQUIRED (Fonts) — satori cannot render text without a font buffer, and there is no bundled default (that would ship a license artifact). Missing font → a named build/boot error with the fix. The renderer uses the ORIGINAL un-subsetted files, so glyphs outside your declared subsets still render.
ssrpages needVOLTRO_OG_SECRETin multi-replica deploys. The render signs the on-demand URL (HMAC over route + params + tenant + locale; tampering answers 403) and a per-boot minted secret only verifies in the process that signed it — behind a load balancer set the env var (same value on every replica), or the deploy boot refuses, loudly. Images are cached in the same backend as the page cache; tenant and locale are part of the key wherever the template uses them.- Emoji are not supported — satori renders them only via a per-glyph CDN
fetch, which collides with the no-external-requests posture. Use an image
in the template instead: a
?imageimport'sblurDataURL/src, or any data-URI (src: \data:image/png;base64,…`) inside animg` element of the template — the standard avatar/logo card works that way.
Anti-patterns
- Calling
ctx.ai.generate(...)in a loader without timeouts. Loaders shouldn't take >2s. For slow data, render a Suspense fallback +useSubscriptionafter hydration. - Loaders that mutate state. Loaders are reads — they're cached, retried, run at build time. Use mutations for writes.
- Hardcoding env-only secrets in
metatags.metaships to the client. Public meta only.
Where to read next
- Navigation — client-side routing, prefetch, Link
- Render modes — which mode means what for loaders