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 server-side 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.

Both are static module exports — the framework discovers them, the build pipeline runs them.

A loader

// src/pages/notes/[id].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> => {
  // Server-side fetch — runs on the Node side, never in the browser.
  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.

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.

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

The loader context carries pathname, not a request object. For the query string during SSR, read it from useServerRequest().url inside the component.

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].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.

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:lang 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 { localeCanonicalUrl } from '../lib/seo'

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:   localeCanonicalUrl('/notes', locale),
  }
}

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.

Examples

// src/pages/dashboard/index.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].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.

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.