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?.project

A 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 not undefined until 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-backed AuthenticationProvider has 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:

  • query is absent in the browser, so if (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.
  • headers is {} in the browser, not undefined — so if (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:

  • query is server-only. It's undefined for client-side loader invocations (SPA navigation re-runs the loader in the browser). Guard it (query ? … : undefined) and use useSubscription in the component for the reactive, after-hydration path. The loader's query is 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. A type:'web' app has no auth middleware, so auth.strategies in a web app.config.ts does nothing. Configure your IdP (supabaseStrategy, workosStrategy, the built-in password strategy, …) on the api's app.config.ts.
  • ctx.query forwards the request's cookie automatically. A server-side loader's ctx.query sends the browser's Cookie header on the one-shot POST /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's sb-<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:

  1. Catches the throw.
  2. Renders the page's error.tsx (or the nearest ancestor's) with the error.
  3. 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 } where canonical is this locale's URL and links is one rel="alternate" per locale (each an absolute URL, as Google requires) plus hreflang="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.xml lists every prerendered route (minus noIndex ones). With 2+ locales, each URL carries the full xhtml:link alternate set. Written only when seo.siteUrl is set — absolute URLs are required.
  • robots.txt is generated even without siteUrl. Production allows all and advertises the sitemap; voltro dev — and any build with VOLTRO_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.txt always wins — the generator never overwrites one.

Examples

// 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.
  • ssr pages need VOLTRO_OG_SECRET in 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 ?image import's blurDataURL/src, or any data-URI (src: \data:image/png;base64,…`) inside an img` 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 + useSubscription after 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 meta tags. meta ships to the client. Public meta only.