# Routing

> Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework.



---

<!-- source: en/routing/overview.md -->
## Overview

_Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework._

The web side of a Voltro app uses **file-based routing**: drop a `page.tsx` file under `src/pages/`, the CLI discovers it on every boot + save, and the file becomes a route. No router config, no manual `<Route>` declarations, no codegen step.

The suffix is what makes a file a route — the directory tree only decides *which URL*. Anything else under `src/pages/` (components, hooks, tests) is ordinary code and gets no URL, so it can live beside the page that uses it.

This section covers everything about how URLs map to React + how Voltro decides when to render, what to ship to the browser, and how to navigate between pages.

## The shape of it

```text
src/pages/
├── layout.tsx              # outer shell (wraps every page)
├── error.tsx               # error boundary
├── loading.tsx             # pending UI
├── not-found.tsx           # 404 fallback
├── page.tsx               # /
├── about/page.tsx               # /about
├── (marketing)/            # route group — no URL segment
│   ├── layout.tsx          # marketing-scoped layout
│   └── pricing/page.tsx         # /pricing
├── users/
│   ├── layout.tsx          # users-scoped layout
│   ├── error.tsx           # users-scoped error boundary
│   ├── [id]/page.tsx            # /users/:id
│   └── page.tsx           # /users
└── docs/
    └── [...slug]/page.tsx       # /docs/<anything>  (catch-all)
```

That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.

## What's in this section

- [Pages & dynamic segments](/docs/routing/pages) — file → URL mapping, `[id]`, `[...slug]`, query params
- [Layouts & route groups](/docs/routing/layouts) — `layout.tsx`, error boundaries, `(group)/` directories
- [Render modes](/docs/routing/render-modes) — `static` vs `ssr` vs `isr`, when to use each
- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data fetch + `<head>` injection
- [Navigation](/docs/routing/navigation) — `Link`, `useNavigate`, prefetch on hover
- [Islands](/docs/routing/islands) — hydrating only the interactive bits, JS-free pages

## Architecture in one paragraph

The framework generates a `.framework/app.tsx` on every boot that imports each `page.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.

You don't write any of this. The CLI regenerates it on every save in dev; the build pipeline freezes it for production.

## Embedding the runtime in a foreign host

To mount Voltro's reactive runtime *inside* an app that owns its own routing (a Next.js / Remix / existing React shell), skip the generated boot and use the embeddable provider from the light `@voltro/web/runtime` subpath:

```tsx
import { VoltroRuntimeProvider, type MountedApi } from '@voltro/web/runtime'

const apis: ReadonlyArray<MountedApi> = [
  { name: 'app', group, descriptors, wsUrl: 'wss://your-api.example.com/ws' },
]

export const Providers = ({ children }: { children: React.ReactNode }) => (
  <VoltroRuntimeProvider apis={apis}>{children}</VoltroRuntimeProvider>
)
```

Inside it, every `@voltro/client` hook (`useSubscription`, `useMutation`, `useAction`, …) resolves exactly as in a native Voltro app — it builds + supervises the per-api RpcClient-over-WebSocket + runtime + subscription cache and handles reconnect. `children` render immediately (a pending api reads `undefined` until connected — no spinner gate), so a prerendered host hydrates without a mismatch. The `@voltro/web/runtime` subpath pulls only the rpc/socket/runtime graph, not the router/mount/SSR — keeping the host bundle small. (`FrameworkBoot`, the framework's own web boot, is a thin wrapper over this provider.) Consume the framework as the **published** package — a `file:` link to an unbuilt checkout resolves the raw `src` export, which a foreign bundler can't handle.

## Conventions

| Pattern | Behaviour |
|---|---|
| `index.tsx` in a directory | Maps to the directory's URL (no segment for the filename). |
| `[name].tsx` | Dynamic single segment. Available via `useParams<{ name: string }>()`. |
| `[...rest].tsx` | Catch-all. `useParams<{ rest: string }>()` joins the captured path with `/`. |
| `[[...rest]].tsx` | Optional catch-all. Matches both `/foo` and `/foo/bar/baz`. |
| `(name)/` | Route group. Strips the segment from the URL but layouts inside still apply. |
| `_*.tsx` | Private — discovery skips it. Useful for helpers next to pages. |
| `*.island.tsx` | Hydration island. Bundles as a separate chunk; hydrated only when imported. |

## When NOT to use file-based routing

The pattern works for ~99% of apps. Edge cases:

- **Programmatically generated routes** — when you can't know the URL ahead of time. Use `[...slug].tsx` + match inside.
- **i18n with URL-segment locale** — see [URL strategies](/docs/i18n/url-strategies) for the shipped URL-prefix approach (`/de/docs/foo`).
- **Cross-tenant subdomains** — handled at the reverse-proxy layer; the file-based router serves one host's paths.

For everything else, drop a file + done.

## Where to next — building the UI inside the page

Routing gets you to a page; the **[Schema-driven UI](/docs/ui/overview)** section
fills it. It's the other half of the frontend story:

- **[Forms & tables](/docs/ui/forms-and-tables)** — `<AutoForm>` binds to a
  mutation, `<DataTable>` to a query; fields + columns come from the
  descriptors' `effect/Schema`, validation + live auto-optimistic for free.
- **[Reactive components](/docs/ui/reactive-components)** — drop-in workflow
  progress, presence/multiplayer, and AI chat over the durable backend.
- **[Client utilities](/docs/ui/client-utilities/use-can)** — the bound-hook toolbox
  beyond `useSubscription`/`useMutation`: `useCan`, `useDerived`, `usePreview`,
  `useUndo`, `useProvenance`, `useAsyncValidation`, `useOutbox`,
  `useWindowedSubscription`, and more.



---

<!-- source: en/routing/pages.md -->
## Pages & dynamic segments

_Filesystem → URL mapping, dynamic [id] segments, catch-all [...slug] queries, query params, and private files._

A **page** is any `*.tsx` file under `src/pages/` that's not a special file (`layout.tsx`, `error.tsx`, `loading.tsx`, `not-found.tsx`) and doesn't start with `_`. Its default export is the page component; the URL comes from the file path.

## A static page

```tsx
// src/pages/about/page.tsx → /about
import type { ReactNode } from 'react'

export default function About(): ReactNode {
  return (
    <div className="max-w-2xl mx-auto py-12 px-6">
      <h1 className="text-3xl font-bold">About us</h1>
      <p>Voltro Cloud is a framework for shipping multi-tenant SaaS.</p>
    </div>
  )
}
```

That's it. Save the file, the CLI's discovery sees it on next save, the page is live at `/about`.

## index files

`index.tsx` maps to the directory's URL:

```text
src/pages/page.tsx         → /
src/pages/users/page.tsx   → /users
src/pages/admin/page.tsx   → /admin
```

## Dynamic segments

Brackets in the filename are dynamic. The captured value comes through `useParams<T>()`:

```tsx
// src/pages/users/[id]/page.tsx → /users/:id
import { useParams } from '@voltro/web'

export default function User() {
  const { id } = useParams<{ id: string }>()
  return <h1>User {id}</h1>
}
```

Multiple dynamic segments in one path:

```text
src/pages/orgs/[orgId]/projects/[projectId]/page.tsx
// → /orgs/:orgId/projects/:projectId

const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
```

## Catch-all queries

`[...name]` captures one OR more URL segments as a single param value (joined by `/`):

```tsx
// src/pages/docs/[...slug]/page.tsx → /docs/<anything>
const { slug } = useParams<{ slug: string }>()
// /docs/intro/getting-started → slug = "intro/getting-started"
```

**Optional** catch-all (matches the base URL too):

```tsx
// src/pages/docs/[[...slug]]/page.tsx
// /docs       → slug = ""
// /docs/foo   → slug = "foo"
// /docs/foo/bar → slug = "foo/bar"
```

## Priority

When multiple files could match (static, dynamic, catch-all), priority is:

1. Static segments win over dynamic.
2. Dynamic single (`[id]`) wins over catch-all (`[...slug]`).
3. Optional catch-all (`[[...slug]]`) wins over required catch-all (`[...slug]`) — the optional form scores as more specific, so it matches first.

```text
src/pages/users/page.tsx     # /users → wins for /users
src/pages/users/[id]/page.tsx       # /users/:id → wins for /users/42
src/pages/users/new/page.tsx        # /users/new → wins (static beats dynamic)
src/pages/[...rest]/page.tsx        # everything else
```

## Query strings

Query params are orthogonal to the URL pattern — they never appear in the file path. A page declares its query contract as a **`searchParams` schema export**, the same page-export convention as `meta`, `loader`, and `renderMode`:

```tsx
// src/pages/search/page.tsx
import { Schema } from 'effect'
import { useSearchParams } from '@voltro/web'

export const searchParams = Schema.Struct({
  q:    Schema.optionalWith(Schema.String, { default: () => '' }),
  page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
})

export default function SearchPage() {
  const { q, page } = useSearchParams(searchParams)   // q: string · page: number
  // …
}
```

`useSearchParams(searchParams)` — the page passes its **own** export — returns the decoded, typed shape, SSR-aware: the same call site reads the request URL on the server and `window.location.search` on the client. An invalid query string falls back to the schema's defaults instead of crashing the render, so every field must be optional or carry a default (`Schema.optionalWith(..., { default })` — a schema that cannot decode an empty query throws at the first read, naming the fix). Links to the route type-check against the same schema via [typed `withQuery`](/docs/routing/navigation#typed-withquery).

Two boundaries worth knowing:

- **`renderMode: 'isr'` + a `searchParams` export is refused at boot** — the isr cache is keyed by path (plus tenant + locale), so the first query's variant would be cached and served for every other query. Use `ssr`, or drop the export and read the query client-side only. See [Render modes](/docs/routing/render-modes#isr-incremental-static-regeneration).
- **`renderMode: 'static'` build renders see only the schema's defaults** — a build has no query string. The client decodes the live query after hydration; a static page keyed off search params is a client-side concern.

### Mirror routes share ONE schema

Bilingual apps with mirrored trees (`pages/x/page.tsx` + `pages/[locale]/x/page.tsx`) re-export the original page's schema instead of copying it:

```tsx
// src/pages/[locale]/search/page.tsx
export { searchParams } from '../../search/page'
```

One schema, no drift — the mirror page decodes exactly what the original declares.

### Routes without a schema

`useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:

```tsx
import { useSearchParams } from '@voltro/web'

const q = useSearchParams().get('q') ?? ''
```

## Co-locating components, hooks and tests

Only `page.tsx` is a route. Everything else under `src/pages/` is ordinary code and may sit next to the page that uses it:

```text
src/pages/
├── users/
│   ├── page.tsx        # → /users
│   ├── page.test.tsx   # its test
│   ├── [id]/page.tsx         # → /users/:id
│   ├── UserCard.tsx          # a component — no URL
│   └── useFilters.ts         # a hook — no URL
```

No naming trick is needed to keep something out of the router: the absence of the suffix already does it. A `_`-prefixed directory has **no special meaning** — it is neither required nor recognised.

> Before this convention, every `.tsx` under `src/pages/` became a route, so a co-located component silently got a URL. That route rendered nothing and nobody visited it in dev; the failure surfaced at the first production build. If you are upgrading, `voltro update` renames your pages for you.

## Two routes, one screen

When two URLs must render the same component — a versioned path kept alive because devices in the field are configured against it, say — re-export it instead of copying it:

```tsx
// src/pages/v2/page.tsx → /v2, rendering exactly what / renders
export { default, renderMode } from '../page'
```

`export { default } from '…'` satisfies the page contract: the module has a default export, it just did not declare it here. Forwarding `renderMode` alongside it is what keeps the two routes from drifting apart — the build follows the forward when it computes the render profile, so `/v2` is classified the same as `/`, not silently as `static`.

`export { default as Screen } from '../page'` is the opposite: it renames the default away, leaving this module without one. That still fails the contract.

## Trailing slashes

The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.

`Link` forwards every prop it does not consume itself to the underlying `<a>`, `ref` included — so it drops straight into a polymorphic slot (`<Button component={Link} to={url}>`) without a wrapper.

The framework does NOT emit a trailing-slash redirect on its own. If you need `/about/` → `/about` normalisation (for SEO), configure a 301 redirect at your reverse proxy.

## What pages CAN'T do

- **Live outside `src/pages/`.** Discovery walks one root. Helpers + components go elsewhere; pages go here.
- **Have multiple default exports.** One page per file.
- **Be `.ts` files.** Pages must be `.tsx` — React components only.
- **Be discovered via dynamic import.** The CLI generates the import statements at boot; runtime adds need a re-discover (which `voltro dev` does on save).

## Where to read next

- [Layouts & route groups](/docs/routing/layouts) — wrap pages in shared chrome
- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data + `<head>` tags
- [Render modes](/docs/routing/render-modes) — `static` / `ssr` / `isr`



---

<!-- source: en/routing/layouts.md -->
## Layouts & route groups

_layout.tsx, error.tsx, loading.tsx, not-found.tsx, route groups (group)/, and how the chain composes._

A **layout** wraps every page in its subtree. Drop a `layout.tsx` in a directory and every page below it gets wrapped — outer layouts compose around inner ones automatically.

The same pattern handles error boundaries (`error.tsx`), pending UI (`loading.tsx`), and 404 fallbacks (`not-found.tsx`). These are the four **special files**; discovery treats them differently from regular pages.

## Root layout

```tsx
// src/pages/layout.tsx — wraps EVERY page
import type { ReactNode } from 'react'
import './globals.css'

export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
  return (
    <div className="min-h-screen bg-background text-foreground">
      <header>{/* topbar */}</header>
      <main>{children}</main>
      <footer>{/* footer */}</footer>
    </div>
  )
}
```

Conventions:

- **Don't render `<html>`/`<head>`/`<body>`.** The framework's `index.html` shell owns those. Rendering them inside React puts them under `#root` + the browser unwraps them, breaking the document structure.
- **Set `<html class>` via `theme:` in `app.config.ts`.** Bakes the dark/light class into the shell before first paint.
- **Set `<title>` + meta via the page's `meta` export.** See [Loaders & meta](/docs/routing/loaders-and-meta).

## Nested layouts

```text
src/pages/
├── layout.tsx                # outer (every page)
├── about/page.tsx                 # /about → wrapped in outer layout
└── dashboard/
    ├── layout.tsx            # nested (only /dashboard/*)
    ├── page.tsx             # /dashboard
    └── settings/page.tsx          # /dashboard/settings
```

For `/dashboard/settings`, the React tree is:

```text
<OuterLayout>
  <DashboardLayout>
    <Settings />
  </DashboardLayout>
</OuterLayout>
```

Outer layouts compose around inner ones. Each layout's state survives navigation **within** its scope — moving from `/dashboard` to `/dashboard/settings` doesn't unmount `DashboardLayout`.

## Route groups

A directory in `(parentheses)` does NOT contribute a URL segment, but its layout still applies. Useful when you want a layout for a logical group of pages without nesting their URLs.

```text
src/pages/
├── (marketing)/
│   ├── layout.tsx            # marketing-scoped chrome
│   ├── page.tsx             # /
│   ├── pricing/page.tsx           # /pricing
│   └── about/page.tsx             # /about
└── (app)/
    ├── layout.tsx            # authenticated app chrome
    ├── dashboard/page.tsx         # /dashboard
    └── settings/page.tsx          # /settings
```

Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.

Use this when:

- The marketing landing + product app share root URL paths but have completely different chrome.
- You want layout state to NOT persist across logical sections (moving from `/about` to `/dashboard` unmounts everything).

## Error boundaries

```tsx
// src/pages/error.tsx — catches errors from any page below
import type { ReactNode } from 'react'

interface ErrorProps {
  readonly error: Error
  readonly reset: () => void
}

export default function ErrorPage({ error, reset }: ErrorProps): ReactNode {
  return (
    <div className="text-center py-12">
      <h1 className="text-2xl font-bold mb-2">Something went wrong</h1>
      <p className="text-muted-foreground mb-6">{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}
```

- Catches errors from page renders + loaders + any descendant's React tree.
- `reset()` re-renders the boundary — call after fixing whatever caused the throw.
- Scoped: `src/pages/dashboard/error.tsx` only catches errors from `/dashboard/*`.

## Pending UI (opt-in)

Navigation is **deferred** by default: clicking a link keeps the CURRENT page on screen until the target route's `loader`s settle, then swaps. There is **no full-screen loading overlay**. Background progress shows in the devtools button (dev) or a minimal corner indicator (production) — both read the same status bus.

`loading.tsx` is an **opt-in** override for one route: export it (or a page-level `Pending`) ONLY when you want that route to swap in immediately and show a skeleton instead of holding the previous page.

```tsx
// src/pages/dashboard/loading.tsx — opt-in skeleton for /dashboard/*
export default function Loading(): ReactNode {
  return <div className="animate-pulse">Loading…</div>
}
```

`loading.tsx` is driven by in-flight loaders, not by subscriptions — a `useSubscription` returns `data: undefined` until its first snapshot rather than suspending. Render that empty state inside the page itself.

Scoped like `error.tsx`.

## Not-found

```tsx
// src/pages/not-found.tsx — 404 fallback
export default function NotFound(): ReactNode {
  return (
    <div className="text-center py-12">
      <h1>Page not found</h1>
      <a href="/">← Home</a>
    </div>
  )
}
```

Scoped: `src/pages/dashboard/not-found.tsx` catches 404s only for URLs starting with `/dashboard/`. Useful for tenant-specific 404 messaging.

## The default fallback chrome (and localizing it)

Until you supply your own `error.tsx` / `not-found.tsx`, the router renders a built-in diagnostic chrome — a runtime-error card (error name, stack, Retry / Reload / Copy) and a 404 card. It's a dev-diagnostic surface, not app UI, so it ships full detail regardless of environment. Two ways to change it:

- **Replace it** — export your own `error.tsx` / `not-found.tsx` (above), or pass `errorFallback` / `notFound` components to the `<Router>`. Total control.
- **Just relabel it** — wrap the app in `<FallbackStringsProvider>` to override the built-in chrome's English strings without rebuilding the components (e.g. to localize "Retry" / "Reload page" / "No page for this URL"):

```tsx
import { FallbackStringsProvider } from '@voltro/web'

<FallbackStringsProvider
  strings={{
    error: { retry: 'Wiederholen', reload: 'Seite neu laden', copy: 'Fehler kopieren' },
    notFound: { heading: 'Keine Seite für diese URL' },
  }}
>
  {/* your app */}
</FallbackStringsProvider>
```

Overrides deep-merge onto the English defaults — supply only the keys you change; the rest stay English. A per-component `strings` prop on `DefaultErrorFallback` / `DefaultNotFound` wins over the provider for a one-off. (Mirrors `<UiStringsProvider>` for the `@voltro/ui` kit.)

## How the framework composes them

For a request to `/dashboard/settings`, the framework walks the page tree:

1. Find the matching leaf — `dashboard/settings.tsx`.
2. Walk up the directory tree, collecting every directory's special files in order.
3. Build the chain: outermost layout → next layout → … → leaf page.
4. Wrap each layer's `error.tsx` as a React Error Boundary around the next layer.
5. Render.

The generated `.framework/app.tsx` records this chain explicitly per route — you can `cat` it to see the result.

## Limits

- **One `layout.tsx` per directory.** Multiple would be ambiguous.
- **Layouts can't be async functions.** Use a `loader` for data + read it via `useLoaderData`.
- **Error boundaries don't catch loader errors.** Loader errors render the page's `error.tsx`; throwing inside the page's render does too. Both flow through the same boundary.
- **Layouts can call `useLocation()`, `useParams()`, `useServerRequest()`.** They're React components like any other.

## Where to read next

- [Render modes](/docs/routing/render-modes) — how `static` / `ssr` / `isr` interact with layouts
- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data + page-level `<head>` tags



---

<!-- source: en/routing/render-modes.md -->
## Render modes

_static (SSG) vs spa vs ssr vs isr — when each runs, what it caches, and how to pick._

Every page declares a `renderMode`. The mode controls **when** the HTML is produced — at build time, on every request, build-once-revalidate-occasionally, or not on the server at all.

```tsx
export const renderMode = 'static' as const   // 'static' | 'spa' | 'ssr' | 'isr'
```

## The four modes

| Mode | When HTML is produced | Cached? | Best for |
|---|---|---|---|
| `static` *(default)* | `voltro build` time | Forever | Marketing pages, docs, anything that doesn't change per-request |
| `spa` | Never for the page itself (its layout chain may still be server-rendered) | — | Reactive dashboards whose state lives in the browser |
| `ssr` | Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
| `isr` | First request after build, then on revalidate | Per-key in-memory or Postgres | News feeds, listings, dashboards that change but not per-user |

Those four are the **complete** set. An unrecognised value is a hard error naming the page — see [What doesn't work](#what-doesnt-work).

## static (SSG)

```tsx
// src/pages/about/page.tsx
export const renderMode = 'static' as const
```

At `voltro build`:

1. The framework runs the page's render once (with `useServerRequest()` returning `null`).
2. The output HTML lands at `dist/about/index.html`.
3. `voltro start` serves the file directly — no React runs on the server.

For dynamic patterns, export `getStaticPaths` to enumerate every URL to pre-render:

```tsx
// src/pages/blog/[slug]/page.tsx
export const renderMode = 'static' as const

export const getStaticPaths = async () => [
  { params: { slug: 'first-post' } },
  { params: { slug: 'second-post' } },
]
```

One HTML file per entry lands in `dist/blog/first-post/index.html` etc.

Pages that don't enumerate (dynamic without `getStaticPaths`) fall through to the SPA shell — the client-side router takes over.

## ssr

```tsx
export const renderMode = 'ssr' as const
```

On every request:

1. `voltro start` matches the URL → finds your page.
2. Calls the loader (if any) with the request's params + headers + cookies.
3. Renders the React tree to HTML.
4. Returns it.

Use SSR for:

- **Authenticated pages** that read the session cookie via `useServerRequest()`.
- **Personalised content** — recommendations, "your" anything.
- **Search result pages** — the query string changes per request.

### What the server render sees

An SSR render is given the request, not a guess at it: the matched `pathname`
and `params`, the request's cookies and headers (`useServerRequest()`), and the
**query string**. `useSearchParams()` is the supported reader and works on both
sides — on the server it reads the request URL, on the client
`window.location.search` — so a page keyed off `?tab=…` renders the same markup
in both places.

That last part is worth stating because it is the thing a mismatch is made of.
Anything the server renders from a value the client computes differently
hydrates with a warning, even when the DOM happens to agree; reach for the hook
rather than reading the router context directly.

Cost: every request triggers a fresh render. For very high-traffic pages, prefer ISR.

### Streaming SSR

An `ssr` page is **streamed**, on both boot paths (`voltro dev` and `voltro
start`). The server sends the `<head>` and the page shell as soon as they are
rendered, then flushes each `<Suspense>` boundary as its data settles, inside
the same response.

For a page with no deferred data this is a time-to-first-byte win and nothing
else — React's render loop still runs to completion in one pass, so a slow
render is still a slow render. The lever that matters is `defer()`: it puts a
real `<Suspense>` boundary in the tree, which is what lets the server return to
the event loop while a slow value is still pending. See
[Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await).

Measured against a real server rendering a page with one 400ms deferred field:
first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
10ms for the duration of that request was served 30 times with a 3ms median and
a 7ms maximum. The request does not occupy the event loop while it waits.

Two consequences worth knowing:

- **The entry script is emitted as a bootstrap module.** A plain
  `<script type="module">` is deferred until the document finishes parsing,
  which on a streamed response is *after the last deferred boundary* — the
  framework hands the URL to React instead, so it goes out `async` at the end
  of the shell and hydration starts immediately.
- **`isr` and `static` are still buffered**, because both produce a stored
  artefact rather than a response. That is also why `defer()` is an error on
  those modes.

Apps do not call the renderer directly. If you are building your own server on
top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
same options as `renderPageToHtml` plus `bootstrapModules` and the stream
callbacks, resolves `meta` synchronously so the `<head>` can go out first, and
returns a Node pipeable stream.

## isr (incremental static regeneration)

```tsx
export const renderMode = 'isr' as const
export const revalidate = '60 seconds'   // re-render when older than this
```

A bare number is read as **seconds** (Next.js compat) — `revalidate = 60` means 60 seconds, NOT milliseconds. Use the string form (`'60 seconds'`, `'5 minutes'`, `'1 hour'`) for clarity.

Behaviour:

| Request | Action |
|---|---|
| First | MISS — render, store in cache, serve. |
| Subsequent (cache fresh) | HIT — serve from cache. |
| After revalidate window | MISS — re-render, store, serve. |
| With `staleWhileRevalidate` | STALE — serve cached HTML immediately, kick off background refresh. |

```tsx
export const renderMode = 'isr' as const
export const revalidate = '60 seconds'
export const staleWhileRevalidate = '60 seconds'   // serve stale while refreshing in bg
```

Cache backends:

- `memory` *(default)* — in-process, doesn't survive restarts.
- `postgres` — `SSR_CACHE=postgres`. Survives restarts, shared across api instances.

An `isr` page that also declares a [`searchParams` schema](/docs/routing/pages#query-strings) is refused at boot — the cache is keyed by path (plus tenant + locale), not by query, so the first query's variant would be served for every other query; use `ssr`, or drop the export and read the query client-side only.

### isr renders are anonymous

An `isr` render is a **shared** render: the HTML it produces is cached and served
to every visitor inside the revalidate window. The framework therefore strips
credential material before the render runs — the cookie jar (except the
`voltro:locale` cookie), the `authorization` header, and every `x-voltro-*`
header never reach an isr page's loaders, `ctx.query`, or `useServerRequest()`.
`x-tenant` and `accept-language` survive, because the cache key (tenant + locale)
is derived from them.

Concretely: a loader on an isr page that reads subject-scoped data gets the
**anonymous** answer — the same one every visitor will see — instead of caching
the first visitor's data for everyone. This applies identically under
`voltro dev` and `voltro start`, so a page cannot look personalised in dev and
silently serve shared HTML in production. A page whose loader needs the signed-in
subject belongs on `renderMode: 'ssr'`.

## Tenant-aware ISR

For multi-tenant ISR (each tenant gets its own cache entry):

```tsx
export const renderMode = 'isr' as const
export const revalidate = '60 seconds'
export const tenantAware = true
```

The cache key becomes `${pathname}|tenant=${tenant}`, where `tenant` is the request's `x-tenant` header (falling back to `anonymous`). Tenant A's cached HTML never serves to tenant B.

## CDC-invalidated ISR

When a specific DB write should invalidate the cache (instead of waiting for the revalidate window):

```tsx
export const renderMode = 'isr' as const
export const cacheInvalidatesOn = ['posts', 'comments']   // tables to watch
```

The framework reads Postgres logical replication; writes to `posts` or `comments` invalidate every cached HTML for this query. New requests rebuild the page from the current data.

Requires `SSR_CACHE=postgres` and a `wal_level=logical` Postgres.

## On-demand revalidation

The third invalidation axis, next to time (`revalidate`) and CDC
(`cacheInvalidatesOn`): server code in the api process drops ISR cache entries
imperatively, on **every** web replica — including on dialects that have no
CDC at all (sqlite, mysql, memory), which is the case this exists for.

```ts
import { revalidatePath, revalidateTable, revalidateTag } from '@voltro/runtime'

// inside a mutation / action / webhook receiver / REST route handler:
await revalidateTable('posts')          // drop every route whose cacheInvalidatesOn lists 'posts'
await revalidatePath('/blog/[slug]')    // drop every cached instance of the route
await revalidatePath('/pricing')        // drop one concrete path (all tenant+locale variants)
await revalidatePath('/pricing', { tenant: 'acme' })  // …one tenant's variants only
await revalidateTag('pricing')          // drop every route whose cacheInvalidatesOn lists the tag
```

**Tags are tables that never were one.** `cacheInvalidatesOn` accepts free
strings, so one mechanism covers both: declare `cacheInvalidatesOn:
['posts', 'pricing']` on any number of routes and `revalidateTag('pricing')`
drops them all — the `revalidateTag` thinking Next.js users bring works
unchanged.

**How it travels.** The api process publishes; every `voltro start` replica
subscribes. Two transports, either or both:

- **postgres**: a `pg_notify` on the same LISTEN connection the CDC
  invalidator already holds — a postgres deployment needs **no broker**.
- **a broker**: set `BROADCAST_URL` (`redis://` or `nats://`) on **both** the
  api and the web deployment. This is the path for non-postgres dialects. On
  a broker shared by several projects, also set `VOLTRO_BROADCAST_NAMESPACE`
  on both sides — the channel is namespaced by that variable (the api's and
  the web app's names differ, so a name-derived namespace can't pair them).

A web process with ISR routes and **neither** transport warns at boot
(`NO revalidation transport`) — calls then change nothing and cached pages
live out their own `revalidate` window. Under `voltro dev` there is no ISR
cache; the calls are debug-logged no-ops.

Three edges, all deliberate:

- **Only `isr` routes.** `revalidatePath` against a `static` route logs a
  named error on the web process — static HTML is a build artifact `voltro
  start` never re-renders; rebuild to change it. (The transport is
  fire-and-forget, so the error surfaces in the web replica's log, not at the
  call site.)
- **Purge-during-render is guarded.** A background SWR refresh (or miss fill)
  that started before the purge landed is discarded instead of writing the
  pre-purge page back with a full TTL — a per-key generation counter, on both
  cache backends.
- **On postgres you don't need this for the plain publish case** — a route
  declaring `cacheInvalidatesOn: ['<table>']` is already dropped by CDC when
  the table changes. Reach for the imperative API for non-postgres dialects,
  pattern purges of routes whose loaders read data indirectly, and tag
  fanout.

## spa (client-only, with an optional SSR layout shell)

```tsx
export const renderMode = 'spa' as const
```

A `spa` page renders entirely in the browser — the page itself is never server-rendered. Reach for it when a page genuinely needs a fresh client render every load (most reactive dashboards) and doesn't need its own first-paint HTML or SEO.

**If the page's route has a layout, that layout is still server-rendered.** The server renders the layout chain — running its layout loaders — around an empty page slot (`<div data-voltro-page-slot>`), inlines the layout data, and marks the page client-only. The browser hydrates that shell and mounts the page into the slot after hydration. So the shell (nav, sidebar, auth gate) gets an instant first paint while the page stays client-only. The page's own `loader` still runs in the browser.

Because a layout now runs on the server for spa routes too, a layout used **only** by spa pages must be SSR-safe — no unguarded `window` / `document` in its render or its `loader`. Layouts shared with any `static` / `ssr` / `isr` page already render server-side (and `static` is the default), so they are unaffected. A spa page with **no** layout is a pure client mount, unchanged.

**`voltro build` prerenders that shell to a file — but only when no layout in the page's chain exports a `loader`.** A layout loader may resolve per-visitor data (the signed-in user, a tenant), and freezing one render of it into a static file would serve the first visitor's data to everyone. So a chain with any layout loader is left to `voltro start`, which runs the loader per request; the build logs which route it skipped and why. A loader-less chain is request-independent by construction and is written to `dist/<route>/index.html`, so a static host paints the layout immediately instead of an empty `#root`.

**On a static host, that file is also the SPA fallback.** A static host answers every URL it has no file for with `index.html` — so once your ROOT route is prerendered, a deep link to `/reports` is served the root's document. The framework handles this: the inlined hydration payload records the pathname it was rendered for, and the client refuses to adopt markup that belongs to another route, falling back to a normal client render instead. Without that check React would hydrate the root's layout while rendering `/reports`, report a hydration mismatch, and silently re-render the whole tree.

Nothing to configure. Two things follow from it, though:

- Deep links into a static deployment are **client-rendered**, not hydrated. The visitor sees the app; they don't get the prerendered paint. If that matters for a route, give it `static` (or `isr`/`ssr` behind `voltro start`) so it has a file of its own.
- The pathname is compared *after* normalising a trailing slash, a trailing `/index.html`, and percent-encoding — the shapes a static host varies on. Your own routes are unaffected.

**A layout in that chain may use `defer()`.** The shell then STREAMS: the layout chain and the empty page slot flush immediately, and the deferred layout value arrives afterwards behind its `<Await>` boundary — the same mechanism an `ssr` page gets, applied to the shell. So a sidebar whose nav counts take 300ms no longer holds back the first paint of the rest of the shell. Nothing about the hydration seam changes: the first flush still carries the empty page slot, and the page still mounts into it after hydration.

What still cannot defer on this path:

- **A prerendered shell.** `voltro build` only prerenders a shell whose chain has no layout loader (see above), and `defer()` can only come from a loader — so the two never meet. If they did, the build would refuse by name rather than freeze the `<Await>` fallback into the file.
- **The page's own loader.** A `spa` page's loader runs in the *browser*, so there is no server render to stream into. Use `<Await>` on a client promise instead, or move the data into a layout loader.
- **`interactive: 'none'` / `'islands'`**, for the same reason as on an `ssr` page: revealing a streamed boundary needs React's inline reveal scripts, and neither mode ever hydrates the root.

## Picking between them

| You have… | Use |
|---|---|
| A truly static page (marketing copy) | `static` |
| A list of known pre-publishable URLs (blog posts) | `static` + `getStaticPaths` |
| A page that changes per user (dashboard, account) | `ssr` |
| A search results page (URL query → result) | `ssr` |
| A blog index that changes when posts are added | `isr` + `cacheInvalidatesOn: ['posts']` |
| A multi-tenant marketing site (`acme.com/[tenant]/pricing`) | `isr` + `tenantAware: true` |
| A status page with 30s-stale acceptable | `isr` + `revalidate = '30 seconds'` |

## What about `interactive`?

`interactive` is **orthogonal** to `renderMode` — it controls how much JS runs in the browser. See [Islands](/docs/routing/islands).

| `interactive` | What's hydrated | JS shipped |
|---|---|---|
| `'none'` | Nothing — pure HTML. | None — the script tags are stripped. |
| `'islands'` | Only `*.island.tsx` files. | The full app bundle, same as `'full'`. |
| `'full'` *(default)* | Whole page. | The full app bundle. |

The third column is the one people get wrong: `'islands'` buys back hydration CPU, not download. Only `'none'` removes bytes. See [Islands](/docs/routing/islands) for the measured numbers.

Combinations:

| `renderMode` × `interactive` | When |
|---|---|
| `static` + `none` | Marketing pages, blog posts. Zero JS. |
| `static` + `full` | SSG with full client-side nav. Docs sites. |
| `ssr` + `full` | Dashboards. The most "Next.js-like" mode. |
| `isr` + `islands` | News feeds with a "like" button island. |

## What gets served when

A request to `/foo`:

1. **Pre-rendered HTML exists at `dist/foo/index.html`?** Serve it. (static + isr-already-cached.)
2. **No pre-render, page is `ssr`?** Render fresh, serve.
3. **No pre-render, page is `isr`?** Cache lookup → MISS → render → store → serve.
4. **No pre-render, page is `spa` with a layout?** Render the SSR layout shell (layouts + an empty page slot) on demand; the client mounts the page into the slot.
5. **No pre-render, page is `spa` with no layout, or `static`?** Serve the SPA shell — the client router takes over.

That last case is how dynamic `static` routes work in dev / when `getStaticPaths` didn't include the URL.

## What doesn't work

- **Any value outside the four modes.** `renderMode` is a closed set — `'static' | 'spa' | 'ssr' | 'isr'`. Anything else (`'client'`, `'csr'`, a typo) fails the build and `voltro dev` at codegen, naming the page, the value and the valid set. There are no aliases: a page that renders only in the browser is `'spa'`.
- **Declaring `renderMode` on a `layout.tsx` / `error.tsx` / `loading.tsx`.** The mode is a property of the PAGE; the framework never reads one off a special file. Whether a layout renders on the server follows from the page's mode.
- **Switching `renderMode` per request.** It's a static module export — one value per build.
- **Assuming `ssr` is client-only in dev.** It is not: `voltro dev` runs the same SSR path `voltro start` does, streaming included, so cookie-driven gates and `useServerRequest()` behave the same in both. What dev does NOT do is pre-render `static` pages — those fall through to the SPA shell.
- **`isr` with `cacheInvalidatesOn` against memory cache.** Memory cache is per-process; CDC events fire across processes. Use `SSR_CACHE=postgres`.

## Where to read next

- [Loaders & meta](/docs/routing/loaders-and-meta) — fetch data before render, inject `<head>` tags
- [Islands](/docs/routing/islands) — scope hydration to explicit islands (note: this reduces hydration work, not the JS payload — `'none'` is the mode that removes bytes)



---

<!-- source: en/routing/loaders-and-meta.md -->
## 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

```tsx
// 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:

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

```tsx
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:

```tsx
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:
>
> ```tsx
> // 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.

```tsx
// 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](/docs/routing/render-modes#spa-client-only-with-an-optional-ssr-layout-shell)).
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

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

```ts
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:

```ts
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:

```tsx
// 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](/docs/data/wire-protocol#http-one-shot-rpc-post-rpc)).

### 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](/docs/plugins/auth-supabase).

### 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](/docs/routing/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`:

```ts
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

```tsx
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:

```html
<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`:

```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:

```tsx
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:

```tsx
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:

```tsx
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](/docs/i18n/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](https://developers.google.com/search/docs/specialty/international/localized-versions) 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`:

```tsx
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`:

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

### Authenticated dashboard with cookie-driven loader

```tsx
// 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](/docs/routing/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):

```tsx
// 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.

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

## Where to read next

- [Navigation](/docs/routing/navigation) — client-side routing, prefetch, Link
- [Render modes](/docs/routing/render-modes) — which mode means what for loaders



---

<!-- source: en/routing/navigation.md -->
## Navigation

_Link, useNavigate, prefetch on hover, programmatic redirects, and the external/hash escape hatches._

Voltro's router is client-side after first paint. Links update the URL via `history.pushState` + re-render the matching page, without a full reload. Loader data prefetches on hover so the next page is ready by the time the user clicks.

## Typed URLs — the `routes` builder

`<Link to=…>` does not take a bare string. Its `to` prop is a **branded
`VoltroUrl`**, minted only by the app's generated `routes` builder or by
`externalUrl()`. This makes a typo or a link to a route that doesn't exist
a compile error instead of a dead link at runtime.

The codegen writes a `routes` builder from your `src/pages/**` tree. Call
the entry for a pattern with its params to get a typed URL:

```tsx
import { Link } from '@voltro/web'
import { routes } from './.framework/routes'   // generated by `voltro dev`

<Link to={routes['/notes/[id]']({ id: '42' })}>Open note 42</Link>
```

- `routes['/pattern'](params)` → `VoltroRouteUrl`. Missing/extra params are a type error.
- `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand. On a route whose page declares a `searchParams` schema, the params type-check against it (below).
- `withHash(url, 'section-3')` — append a `#hash`, keeps the brand.
- `externalUrl('https://example.com')` — the escape hatch for anything the
  codegen can't model: cross-origin, `mailto:`, `tel:`, hash-only, or a
  sibling-app route. A deliberate no-op wrapper so any raw string still has
  to be opted in at the call site.

```tsx
import { withQuery, withHash, externalUrl } from '@voltro/web'

<Link to={withQuery(routes['/notes/[id]']({ id: '42' }), { tab: 'comments' })}>Comments</Link>
<Link to={withHash(routes['/docs/[[...slug]]']({ slug: ['routing'] }), 'priority')}>Priority</Link>
<Link to={externalUrl('mailto:hi@x.com')}>Email us</Link>
```

### Typed `withQuery`

For a route whose page exports a [`searchParams` schema](/docs/routing/pages#query-strings),
the generated builder brands the URL with the schema's decoded shape — through a
**type-only** import, so no page module enters the routes file's value graph and
code-splitting stays intact. `withQuery` then type-checks the params against the
page's contract: a misspelt key or a wrong value type is a compile error.

```tsx
<Link to={withQuery(routes['/notes'](), { page: 2 })}>Page 2</Link>
// withQuery(routes['/notes'](), { pgae: 2 })    → compile error (unknown key)
// withQuery(routes['/notes'](), { page: 'x' })  → compile error (wrong type)
```

The encode is canonical and schema-free: strings pass through, numbers and
booleans via `String()`, arrays become repeated keys (`?tag=a&tag=b`), and
`undefined` params are omitted. A `Date` (or any object) is refused loudly —
there is no canonical URL form the type layer could guarantee; declare the field
as a string/number transform in the page's `searchParams` schema and pass that
instead. Routes of `siblingApps` stay untyped — their pages live in another
app's compile graph.

## `<Link>`

```tsx
import { Link } from '@voltro/web'
import { routes } from './.framework/routes'

<Link to={routes['/notes/[id]']({ id: '42' })}>Open note 42</Link>
```

What it does:

- Renders an `<a href="/notes/42">` so the link is a real anchor (SEO, right-click → "Open in new tab", screen readers, etc. all just work).
- Intercepts plain left-clicks → `history.pushState` + matches the new URL.
- Modifier keys + middle-click + external URLs pass through to the browser's native behaviour.

## Prefetch on hover

```tsx
<Link to={routes['/notes/[id]']({ id: '42' })} prefetch>Open note 42</Link>
```

With `prefetch`, hovering / focusing the link fires the destination's loader in the background. By the time the user actually clicks, `useLoaderData()` resolves immediately on the new page.

Behaviour:

- Idempotent — multiple hovers fire one loader call, results are cached.
- Cached until used or invalidated — a prefetched result stays in the loader cache and is consumed on the next navigation to that route; it isn't discarded on a timer. It's dropped when the route is invalidated (e.g. an error-boundary reset or a mutation that invalidates the loader's data).
- No effect for `static` pages without loaders (nothing to prefetch).

For "everything on the page is prefetchable", apps usually wire `prefetch` on every internal link by default. Not much downside — loaders are cheap; the wasted ones are typically empty.

## `useNavigate`

For programmatic navigation:

```tsx
import { useNavigate } from '@voltro/web'

const SignOutButton = () => {
  const navigate = useNavigate()
  const onSignOut = async () => {
    await fetch('/auth/signout', { method: 'POST' })
    navigate('/login')
  }
  return <button onClick={onSignOut}>Sign out</button>
}
```

Pass a path string. The router updates `window.location.pathname` + renders the new page.

## External + hash URLs

Wrap anything the codegen can't model in `externalUrl()`. `<Link>` detects URLs starting with a scheme (`http://`, `https://`, `mailto:`, `tel:`, `#anchor`) at runtime and falls back to plain browser navigation; a route URL from the `routes` builder does SPA navigation.

```tsx
<Link to={externalUrl('https://example.com')}>External</Link>   {/* opens normally */}
<Link to={externalUrl('mailto:hi@x.com')}>Email</Link>         {/* mailto: handler */}
<Link to={externalUrl('#section')}>Anchor</Link>              {/* in-page scroll */}
<Link to={routes['/dashboard']({})}>Internal</Link>            {/* SPA nav */}
```

## Active link styling

```tsx
import { Link, useLocation } from '@voltro/web'

const Nav = () => {
  const pathname = useLocation()
  return (
    <ul>
      <li><Link to={routes['/']({})} className={pathname === '/' ? 'active' : ''}>Home</Link></li>
      <li><Link to={routes['/about']({})} className={pathname === '/about' ? 'active' : ''}>About</Link></li>
    </ul>
  )
}
```

For "active if URL starts with prefix" (parent nav highlighting):

```tsx
className={pathname.startsWith('/dashboard') ? 'active' : ''}
```

Compose this into your own `NavLink` wrapper with `useLocation()` + `<Link>` when you reuse the pattern across many links.

## Redirects from a loader

When the loader detects "user should be elsewhere":

```ts
import { RedirectError } from '@voltro/web'

export const loader = async ({ headers }) => {
  if (!signedIn(headers)) throw new RedirectError('/login?from=/dashboard')
  return { /* … */ }
}
```

The framework catches it + emits a 303 with `location: /login?from=/dashboard` on SSR; on a client navigation it runs `navigate(..., { replace: true })` so Back doesn't bounce onto the page that redirected. The default status is 303 (a redirect always lands the browser on a GET of the target); pass `{ status: 307 | 308 }` for a method-preserving redirect. The `redirect()` helper is throwing sugar — `if (!signedIn(headers)) redirect('/login')`.

For client-side redirects (e.g. after a button click):

```tsx
const onSubmit = async () => {
  await mutate.run({ /* … */ })
  navigate('/success')
}
```

## Scroll behaviour

By default, the router scrolls to the top on every push navigation. Override per-link:

```tsx
<Link to={withHash(routes['/long-page']({}), 'section-3')}>Jump to section 3</Link>
```

Hash links scroll to the matching `id`. Setting `<Link to={routes['/foo']({})} replace>` replaces the history entry (no back-button entry).

### Back/forward scroll restoration

The router restores the previous scroll position on **back/forward** navigations. It sets `history.scrollRestoration = 'manual'` and owns restoration itself, saving each entry's scroll offset before you leave it and re-applying it (after the target route paints) when you pop back. This is automatic — no setup. Because the router restores after the loader-gated target paints, the offset lands on the right content even for a page that's still fetching when you click Back.

Push/replace navigations still scroll to top (or to the hash target); only back/forward restores.

## View transitions

Opt in to the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) for SPA navigations — the browser cross-fades the old and new page (and lets you animate individual elements) with zero animation library:

```ts
// app.config.ts
export default {
  type: 'web' as const,
  name: 'MyApp',
  router: {
    viewTransitions: true,
  },
}
```

With the flag on, every route swap — `<Link>` clicks, `navigate(...)`, back/forward — runs through `document.startViewTransition`. Individual navigations override the default in either direction:

```tsx
navigate('/reports', { transition: false })   // this one swaps plainly
<Link to={routes['/photos/[id]']({ id })} transition>Open</Link>  // this one transitions even when the app default is off
```

**Fallback is exact.** In a browser without the API, and for users with `prefers-reduced-motion: reduce`, navigation behaves precisely as without the flag — same timing, no animation, nothing to feature-detect yourself.

**Styling is plain CSS, not a framework DSL.** The default is a full-page cross-fade. To animate a specific element independently (the classic shared-element move), give it a `view-transition-name` and style the browser's pseudo-elements:

```css
.post-cover { view-transition-name: post-cover; }

/* Tune the root cross-fade */
::view-transition-old(root) { animation-duration: 150ms; }
::view-transition-new(root) { animation-duration: 150ms; }

/* The named element morphs between its old and new position */
::view-transition-group(post-cover) { animation-duration: 300ms; }
```

An element that keeps its `view-transition-name` across both pages is morphed from its old to its new position automatically — that is the whole shared-element recipe.

Three behaviors worth knowing, all deliberate:

- **`defer()` fields resolve outside the transition.** The transition animates to the committed page — with a deferred field still showing its fallback. The field's later resolution is an ordinary React update, not a second animation. Same rule for an explicit `Pending` skeleton: the swap **to** the skeleton is the transition; the settled content arrives un-animated.
- **Rapid navigation skips, never queues.** Navigating again while a transition is animating skips the running one (per the API's spec) and the last navigation wins — no queue, no dead time.
- **Overlays and modals do not transition.** A view transition snapshots the whole viewport, so running one on an overlay opening would cross-fade the entire page for a change that visually touches one layer. Router view transitions therefore apply to **route navigations only**; overlay/dialog state changes never trigger one.

**Static / multi-page documents:** a full-document navigation (between `renderMode: 'static'` pages, or any MPA link) never goes through the SPA router — opt those into the browser's cross-document transitions with CSS alone, no framework involvement:

```css
@view-transition { navigation: auto; }
```

**Coming from Astro?** There is no `transition:persist` equivalent because none is needed — persistent state lives in a [layout](/docs/routing/layouts), and layouts stay mounted across SPA navigations natively.

## Blocking navigation (unsaved changes)

`useBlocker` holds a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.

```tsx
import { useBlocker } from '@voltro/web'

function EditForm() {
  const [dirty, setDirty] = useState(false)
  const blocker = useBlocker(dirty)   // block while the form has unsaved edits

  return (
    <form onChange={() => setDirty(true)}>
      {/* …fields… */}
      {blocker.blocked && (
        <div role="dialog">
          Discard unsaved changes?
          <button onClick={blocker.retry}>Discard &amp; leave</button>
          <button onClick={blocker.reset}>Stay</button>
        </div>
      )}
    </form>
  )
}
```

When `useBlocker`'s argument is `true` (or a predicate returning `true`) and the user tries to leave — a `<Link>` click, an intercepted `<a>`, or an imperative `navigate` — the navigation is **held** and the hook returns `{ blocked: true, to, retry, reset }`:

- `retry()` — proceed with the held-back navigation.
- `reset()` — cancel it and stay on the page.
- `to` — where the user was trying to go (render it in the prompt if you like).

A full-page unload (tab close, reload, typed URL) additionally triggers the browser's native leave prompt while any blocker is active.

Pass a **predicate** to allow some destinations:

```tsx
// Block everything except an explicit sign-out.
const blocker = useBlocker(({ to }) => dirty && to !== '/logout')
```

## Route announcer (accessibility)

On a full page load a screen reader announces the new page. A client-side SPA navigation swaps the DOM without that announcement — so the router ships a built-in **route announcer**: a visually-hidden `aria-live` region that speaks the new page's title (from the route's `meta`, falling back to the pathname) on every navigation. This is automatic — no setup, nothing to render. Give each route a `meta.title` and the announcement is meaningful:

```tsx
export const meta = () => ({ title: 'Team · Acme' })
```

## History APIs

`navigate` takes a path string only — `(to: string, opts?: { replace?: boolean })`. There is no numeric history overload:

```ts
const navigate = useNavigate()
navigate('/foo')                     // push
navigate('/foo', { replace: true })  // replace the current entry
```

For history traversal, reach for the browser API directly:

```ts
window.history.back()      // back
window.history.forward()   // forward
```

## Reading + writing search params

The recommended way to read the query string is **typed**: declare the page's
query contract as a `searchParams` schema export and pass that same export to
`useSearchParams(...)`:

```tsx
import { Schema } from 'effect'
import { useSearchParams } from '@voltro/web'

export const searchParams = Schema.Struct({
  tab:  Schema.optionalWith(Schema.String, { default: () => 'overview' }),
  page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
  tags: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
})

export default function Notes() {
  const { tab, page, tags } = useSearchParams(searchParams)
  // tab: string · page: number · tags: readonly string[]
}
```

- **SSR-aware** — the same call site decodes the request URL on the server and `window.location.search` on the client.
- **Total** — an invalid query string is never a crash or a 500: the decode falls back to the schema's defaults, exactly like visiting without a query.
- **Every field must be optional or carry a default** (`Schema.optionalWith(..., { default })`). A schema that cannot decode an empty query throws at the first read, naming the fix — that is a definition error, not a runtime input problem.
- **Array fields keep their shape** — `?tag=a&tag=b` decodes to `['a', 'b']`, and a single `?tag=a` decodes to `['a']`, not a bare string.

The same schema types links to the route — see [typed `withQuery`](#typed-withquery)
above — and the page-export convention itself is documented in
[Pages → Query strings](/docs/routing/pages#query-strings).

`useSearchParams()` **without** an argument stays the raw `URLSearchParams` —
the fallback for routes that declare no schema:

```tsx
import { useSearchParams } from '@voltro/web'

const tab = useSearchParams().get('tab') ?? 'overview'
```

Write the query with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:

```tsx
import { useSearchParams, useSetSearchParams } from '@voltro/web'

function Tabs() {
  const tab = useSearchParams().get('tab') ?? 'overview'
  const setParams = useSetSearchParams()
  return (
    <nav>
      <button onClick={() => setParams({ tab: 'overview' })}>Overview</button>
      <button onClick={() => setParams({ tab: 'members' })}>Members</button>
    </nav>
  )
}
```

The setter takes either an object / `URLSearchParams`, or an updater that receives the current params:

```ts
const setParams = useSetSearchParams()
setParams({ tab: 'members' })                              // set the whole query
setParams((p) => { p.set('page', '2'); return p })         // patch one param
setParams({})                                              // clear the query string
```

Search-param writes default to a history **replace** (a filter/tab tweak shouldn't stack a Back entry per keystroke). Pass `{ push: true }` for a distinct history entry, or `{ scroll: false }` to keep the scroll position:

```ts
setParams({ page: '2' }, { push: true })
```

During SSR there is no history to write — read `useSearchParams()` off the request URL for the first paint and call `useSetSearchParams()` on the client after hydration.

### Typed writes

Pass the page's `searchParams` schema to get the **typed** setter. Its object form REPLACES the query — same semantics as the untyped form; a field you leave out decodes to its default on the next read. Its updater form receives the **current decoded params**, so a merge is one explicit spread — the pagination flip that keeps `?filter` stops being a hand-rolled merge:

```tsx
import { useSetSearchParams } from '@voltro/web'
import { searchParams } from './page'

const setParams = useSetSearchParams(searchParams)
setParams({ page: 2 })                          // replaces → ?page=2 (filter dropped)
setParams((p) => ({ ...p, page: p.page + 1 }))  // keeps ?filter — typed merge
```

A misspelt key or wrong value type in the object form is a compile error; in the updater, the typed `p` is the guard (`p.pgae` does not compile).

For a plain `<Link>` that keeps the current query, compose the two primitives you already have — decode the current params, spread them into `withQuery`:

```tsx
const current = useSearchParams(searchParams)
<Link to={withQuery(routes['/search'](), { ...current, page: current.page + 1 })}>Next</Link>
```

That composition is also the whole story on **retaining params across navigations**: there is no implicit retain list — a param survives a navigation only if the link (or setter) encodes it, which keeps every URL self-describing. Spread what must survive; everything else resets to its schema default.

**Layout-level schemas are deliberately not a layer of their own:** the schema is a page export. A layout (or any co-located component) that needs the same params imports the page's schema and calls `useSearchParams(searchParams)` with it — composition per schema import, one schema, no drift.

## Prefetching programmatically

```tsx
import { usePrefetch } from '@voltro/web'

const Card = ({ id }) => {
  const prefetch = usePrefetch()
  return (
    <article onMouseEnter={() => prefetch(`/notes/${id}`)}>
      {/* …card body, no Link inside */}
    </article>
  )
}
```

Useful when the prefetch trigger isn't a `<Link>` (e.g. an entire card area, where the inner link is buried).

## Anti-patterns

- **`<a href>` for internal queries.** Falls through the SPA — full reload. Use `<Link>` instead.
- **`window.location.href = '/foo'`.** Same — full reload. Use `useNavigate()`.
- **`prefetch` on every link blindly.** For cookie-gated loaders that hit DB, hovering 50 nav items can pile up 50 DB queries. Use `prefetch` for high-confidence destinations only.

## Where to read next

- [Loaders & meta](/docs/routing/loaders-and-meta) — what prefetch warms up
- [Islands](/docs/routing/islands) — for pages where most of the JS is stripped



---

<!-- source: en/routing/islands.md -->
## Islands

_interactive: 'islands' — ship pure HTML with selectively-hydrated interactive components._

The **islands** model: serve the page as pure HTML, then hydrate only the bits that need interactivity. The rest of the page stays inert — no React lifecycle runs through it.

Voltro implements islands per-page via the `interactive` export:

```tsx
export const renderMode = 'static' as const
export const interactive = 'islands' as const
```

> **Islands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.**
>
> `voltro build` emits a dedicated browser entry per `interactive: 'islands'` page: react + the island runtime + exactly that page's islands — not the router, not the Effect runtime, not the subscription cache, not the app shell. Measured on the framework's reference fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25): an islands page is **59.6 KB gzipped first-load** vs **181.9 KB gzipped** for the same page as `full` — a factor of ~3. The bundle-budget test additionally pins a hard <70 KB bound AND the ratio (<50 % of the full page). `interactive: 'none'` stays at 0 B.

With `interactive: 'islands'`, the page's HTML is server-rendered and its script tag points at the page's own entry. That entry registers the page's islands, scans for island markers, and hydrates each one on its own schedule — the page component itself never runs in the browser.

Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is planned as **partial prerendering (PPR)**, not part of islands mode.

## When to use islands

- **Marketing pages** with one interactive widget (a pricing toggle, a code playground).
- **Docs** that are mostly text but have a search modal + theme toggle.
- **Blog posts** with an embedded poll or comment widget.

In each case you get back both halves: hydration work runs only inside the islands, and the download shrinks to react + the island runtime + those islands. If the page has no interactive part at all, `interactive: 'none'` is strictly better — it ships no JavaScript.

`interactive: 'none'` does not take forms with it. The strip removes every module script and modulepreload, but leaves `<form>` markup — and the form-flash JSON script (`#__voltro_form_flash__`, inert JSON, not executable code) — in place. An `<AutoForm>` on an `interactive: 'none'` page is therefore fully usable without a single byte of JavaScript: it renders `action="/form/<mutationTag>"` + `method="post"` and submits as a native form POST. Details: [Forms without JavaScript](/docs/ui/forms-and-tables).

## Writing an island

Wrap a component with `island(Component, { name, hydrate })` and default-export the result. The plain component is NOT enough — without the `island()` call the component is never registered, and at hydration time the runtime logs `island "…" not registered`.

`island` comes from the react-only subpath **`@voltro/web/islands`** (only react + react-dom/client in its graph). Importing the `@voltro/web` barrel inside an island file is a BUILD ERROR — see the import rules below.

```tsx
// src/components/LikeButton.island.tsx
import { island } from '@voltro/web/islands'
import { useState } from 'react'

const LikeButton = ({ initial }: { initial: number }) => {
  const [count, setCount] = useState(initial)
  return (
    <button onClick={() => setCount((n) => n + 1)}>
      ❤ {count}
    </button>
  )
}

export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
```

- **`name`** — the stable id under which the component is registered. Must be unique within the app. Both the SSR and the client bundle import the file, so the same `island()` call runs on both sides and registers the component in each.
- **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The six strategies are in the table below.

Use it in a page:

```tsx
// src/pages/blog/[slug]/page.tsx
import LikeButton from '../../components/LikeButton.island'

export const renderMode = 'static' as const
export const interactive = 'islands' as const

export default function Post() {
  return (
    <article>
      <h1>Post title</h1>
      <p>…body content…</p>
      <LikeButton initial={42} />
    </article>
  )
}
```

What happens at build:

1. The page is server-rendered to HTML. The `island()` wrapper emits a marker `<div>` carrying the name, props, and hydrate strategy:
   ```html
   <div data-voltro-island data-island-name="LikeButton"
        data-island-hydrate="visible" data-island-props='{"initial":42}'>
     <button>❤ 42</button>
   </div>
   ```
2. The island compiles into the page's own browser entry — react + the island runtime + this page's islands (see [How the per-page entry works](#how-the-per-page-entry-works)).
3. That entry scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.

The rest of the page stays as inert HTML.

## Hydrate strategies

Each island declares WHEN it hydrates via the `hydrate` option (default `'visible'`):

| Strategy | When the island hydrates | Use for |
|---|---|---|
| `load` | Immediately, as soon as the client runtime mounts | Above-the-fold widgets users touch within the first second — search box, primary CTA. |
| `idle` | When the browser is idle (`requestIdleCallback`, `setTimeout` fallback) | Important widgets that don't need instant interactivity — analytics, secondary nav. |
| `visible` (default) | When the element scrolls into the viewport (IntersectionObserver) | Anything below the fold — comment box, related-articles carousel. |
| `interaction` | On the first pointer / keyboard event on the element | Heavy widgets users *might* touch — embedded playground, deep tree viewer. Defers cost until commitment. |
| `only` | Client-only: the server renders an empty placeholder, the client mounts fresh with `createRoot` instead of hydrating | Components that touch `window` during render — chart/map libraries. |
| `never` | Never — the server-rendered HTML stays inert | Server-only displays that never change after SSR (a build-time status badge). |

Mix freely inside one page: a `load` search box, a `visible` comment widget, and a `never` build banner can all coexist.

## What each mode actually costs

Measured on the framework's reference web fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25) — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:

| Mode | JS shipped | Hydration |
|---|---|---|
| `interactive: 'full'` | ≈181.9 KB gz — the app entry: router, Effect runtime, subscription cache, app shell | The whole page tree |
| `interactive: 'islands'` | ≈59.6 KB gz — a per-page entry: react + the island runtime + this page's islands | Only the marked islands, each on its own strategy |
| `interactive: 'none'` | 0 B — every module script and modulepreload is stripped from the HTML | None |

Two things to take from that table. **`islands` IS a download optimisation now** — an islands page ships roughly a third of the full page's first-load JS, because its entry carries no router, no Effect runtime, no subscription cache and no app shell. And **`none` remains the floor**: it is the only mode that removes the script tags entirely.

Both islands numbers are pinned in CI — the hard <70 KB bound and the <50 %-of-full ratio — so the gap cannot drift shut silently. Reproduce it yourself:

```sh
node packages/web/scripts/bundle-budget.mjs
```

## How the per-page entry works

`voltro build` emits one browser entry per `interactive: 'islands'` page. The build finds the page's islands by walking the page's **relative import graph** for `*.island.tsx` files — transitively, through intermediate components. Only what is reachable from an island file ships; the page component itself may import anything, because on an islands page it never runs in the browser.

Two rules to know:

- **`interactive` must be a source LITERAL.** `export const interactive = 'islands' as const` selects the lean entry; a computed value does not — the page then ships the full entry as before, and the build says so loudly.
- **It applies on all three paths.** `voltro build` (SSG), `voltro start` (ssr/isr islands pages) and `voltro dev` (the same entry mechanism, on demand) — including the import-rule violations below, which fire in dev already, not first in the build.

### What an island may import (build errors, not runtime crashes)

An island file — or anything in its relative import graph — must NOT import:

- `@voltro/web` (the barrel — router hooks, `<Link>`)
- `@voltro/i18n` (`useT`)

An island hydrates provider-less in its own root, so these hooks would throw there — and the barrel would additionally drag the Effect runtime into the lean entry. The build error names the file and the specifier.

Allowed: `@voltro/web/islands`, react, relative browser-safe imports — and `@voltro/client` / `@voltro/ui` (both count as framework usage and trigger the client boot below).

### Framework islands: `useSubscription` and friends

An `@voltro/client` import in the island graph is DETECTED — that page's entry then boots the rpc client (a `VoltroRuntimeProvider` around each island root), and the island receives live data. Pages whose islands are purely presentational never pay the client core.

### Limits

- An islands page reached via **SPA navigation** from a full page runs inside the already-loaded app bundle — the saving applies to the first visit / hard load of the islands page.
- All islands of one page **share one entry** (no per-island lazy chunk) — the hydrate strategies control WHEN an island hydrates, not when it loads.

## Island boundaries

The island component owns its sub-tree's interactivity. Inside an island, you can:

- `useState`, `useEffect`, every React hook
- Import + use any other component
- Render JSX freely

What you CAN'T do:

- Make the *parent page* interactive from inside. The island can't trigger a page-level re-render.
- Read from React Context defined in the page. Each island has its own React root.
- Share state across islands directly. Use the URL, `localStorage`, or a custom message channel.

Each island is independent — there is no shared React root across islands. To coordinate, use the URL, `localStorage`, or a custom message channel.

## Props serialisation

Island props cross the boundary **as JSON in an HTML attribute**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values. `Date` arrives as an ISO string, `Map`/`Set` as `{}`, and functions are lost — in dev the framework warns, naming the island and the prop. Pass JSON shapes and reconstruct richer types inside the island.

OK:

```tsx
<LikeButton initial={42} kind="heart" tags={['blog']} />
```

NOT OK:

```tsx
<LikeButton onClick={() => …} />        // functions can't serialise
<LikeButton date={new Date()} />        // Date → string; use ISO + parse inside
<LikeButton ref={someRef} />            // refs are component-local
```

If you need to pass a function reference, define it INSIDE the island.

## When NOT to use islands

- **Whole page is interactive.** Use `interactive: 'full'` — you'd just be adding the island boot overhead for no benefit.
- **Islands that share state.** Each island is its own root — two islands talking is painful. Coordinate via the URL, `localStorage`, or a message channel.
- **Islands that hydrate immediately and dominate the page weight.** If the island is the whole page minus a header, just go `interactive: 'full'`.

## Combining with render modes

| `renderMode` × `interactive` | Use case |
|---|---|
| `static` + `islands` | Marketing landing with a pricing toggle |
| `static` + `none` | Pure-content blog posts |
| `static` + `full` | SPA-like docs sites |
| `ssr` + `islands` | Personalised pages with a few interactive widgets |
| `isr` + `islands` | High-traffic listings with a "like" button |

## Inspecting

Each islands page gets its own lean entry in `.framework/dist/assets/`, alongside the app entry that full pages share. Listing that directory is the whole report:

```sh
ls -l .framework/dist/assets
```

All islands of one page share that page's entry — there is no per-island lazy chunk. See the mode table above for what actually reaches the browser.

## Anti-patterns

- **Wrapping everything in one big island.** Defeats the purpose — you've just rebuilt full hydration with extra steps.
- **Passing 100 KB of JSON as island props.** The serialised payload ends up in the HTML — pretty quickly an island's "props" cost dwarfs the saved bundle.
- **Calling `useLoaderData` inside an island.** Loaders run for the PAGE, not islands. Islands receive props from the page; the page reads loader data.

## Where to read next

- [Render modes](/docs/routing/render-modes) — pairs with `interactive`
- [Navigation](/docs/routing/navigation) — Link + prefetch work the same on islands pages



---

<!-- source: en/routing/styling.md -->
## Styling (Tailwind v4)

_Tailwind v4 is auto-loaded in every web app's Vite pipeline. The mandatory @source glob, @theme design tokens, and the two silent-failure gotchas (the framework root is .framework/, not src/; a second @theme after @layer is dropped)._

Every Voltro web app's Vite pipeline auto-loads `@tailwindcss/vite`. Apps that
don't use Tailwind pay no runtime cost (the plugin emits nothing when no
`@import "tailwindcss"` appears in any CSS file).

To use it:

```css
/* src/globals.css */
@import "tailwindcss";

/* IMPORTANT: the framework's Vite root is `<app>/.framework/`, not
   `<app>/src/`. Without @source, Tailwind's content scanner misses every .tsx
   file under src/ — you get an empty `@layer utilities` and zero applied
   styles. The path is relative to THIS css file. */
@source "./**/*.{tsx,ts,jsx,js}";

@theme {
  --color-background: #0a0a0a;
  --color-foreground: #fafafa;
  --radius: 0.5rem;
}
```

```tsx
// src/pages/layout.tsx
import '../globals.css'
```

Tailwind v4's `@theme` block doubles as the framework's design-tokens surface —
define your colours / radii / fonts there once and they become available as
`bg-background`, `text-foreground`, etc. The shadcn convention works directly on
top: copy a component's source, the classes resolve.

> **Gotcha:** keep ALL `@theme` blocks ABOVE any `@layer` rules in the same
> stylesheet. A second `@theme` block placed AFTER `@layer base` is silently
> dropped by Tailwind v4 — none of its tokens are emitted.

## Using `@voltro/ui-shadcn` — the kit `@source` is mandatory

If your app imports `@voltro/ui-shadcn/tokens.css`, you MUST declare a SECOND
`@source` pointing at the kit's source — otherwise every class that exists ONLY
inside a kit component (animations like `motion-safe:animate-mesh-drift-a`,
`motion-safe:animate-twinkle`, kit-internal prose variants) is silently dropped
from the generated CSS. The components mount and the keyframes register, but the
`animate-*` utility classes never resolve — no warning is emitted.

```css
/* src/globals.css — app uses kit compositions */
@import "@voltro/ui-shadcn/tokens.css";

@source "./**/*.{tsx,ts,jsx,js}";
@source "../node_modules/@voltro/ui-shadcn/src/**/*.{tsx,ts,jsx,js}";
```

Why: the kit's `tokens.css` ships its own `@source "./**/*"`, but `@source` paths
resolve relative to the IMPORTING css file — so once your `globals.css` imports
the kit, the kit's `./**/*` glob expands to YOUR app's src, not the kit's. The
kit's own components are then never scanned. Declaring the explicit kit path
closes the gap — the published package ships its `src/` precisely so this glob
matches (workspace link and npm install alike).

How to verify: `curl -sS http://localhost:<port>/@fs/<abs>/src/globals.css | grep
animate-` should list every kit animation utility you use. If a class is missing,
the `@source` is misconfigured.



---

<!-- source: en/routing/middleware.md -->
## Middleware

_'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, shape the response (headers, a CSP nonce), and say which routes it runs on.'_

`middleware.ts` at the web app root runs **before** a server render binds its data. It exists for two jobs — renewing a credential the render is about to use, and shaping the RESPONSE (`responseHeaders`, a `cspNonce`) — and it is deliberately narrow about everything else.

```ts
// middleware.ts — server-only. NOT app.config.ts, which is imported into the
// client bundle whenever an api declares `authHeaders`.
import { defineMiddleware } from '@voltro/web/middleware'

export const session = defineMiddleware({
  match: { under: '/app' },
  run: async (req) => {
    const fresh = await refreshSession(req.cookies['sb-session'])
    if (!fresh) return
    return {
      headers:    { authorization: `Bearer ${fresh.accessToken}` },
      setCookies: [{ name: 'sb-session', value: fresh.cookie, maxAge: 3600 }],
    }
  },
})
```

## The problem it solves

A cookie older than the IdP's token lifetime — practically every first page view of the day for a 1-hour token — makes the api resolve 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. The middleware runs earlier than both.

## What it receives, and what it can return

`run` gets a read-only request and returns `{ headers?, setCookies?, responseHeaders?, cspNonce? }` — or nothing, to change nothing. `headers` and `setCookies` shape the REQUEST this render sees; `responseHeaders` and `cspNonce` shape the RESPONSE it produces (their own sections below).

| Field | |
|---|---|
| `req.pathname` | matched path, no query string |
| `req.search` | raw query string including `?`, or `''` |
| `req.headers` | incoming headers, lowercased keys |
| `req.cookies` | the parsed `Cookie` header |
| `req.route` | the matched **route pattern** (`/notes/[id]`), or `undefined` for a non-page request |

Returned headers are merged over the request's, and only auth-shaped names (`authorization`, `x-tenant`, `x-voltro-*`) are forwarded to the api — a returned `host` would otherwise produce failures that look like anything but a header copy. Cookies default to `HttpOnly`, `Path=/`, `SameSite=lax`, and several are written as separate header lines, never comma-joined (a cookie's `Expires` contains a comma).

**Write the cookie back.** An IdP that rotates refresh tokens (Supabase does, and detects reuse) will invalidate the session if you renew server-side and leave the browser holding the consumed one. `setCookies` is not an optimisation.

**A cookie your IdP SDK reads in the browser needs `httpOnly: false`.** The default is `HttpOnly` — right for a cookie only the server touches, and wrong for this one. Supabase's `createBrowserClient` reads the session from `document.cookie`, so a forgotten `false` hands the browser a session it cannot see: the SSR render is perfect, every server-side check passes, and the user is signed out at the first client-side call. `voltro dev` warns once per cookie when a session-shaped name is written with no `httpOnly` decision; setting it explicitly either way silences that.

## What you return applies to THIS render

The middleware produces one view of the request that everything downstream reads:

| Reader | sees |
|---|---|
| `ctx.query` and every `preload` | your headers, and the renewed cookie |
| `ctx.headers` in a loader | your headers |
| `useServerRequest().cookies` / `.headers` | the jar after `setCookies` was applied |
| locale resolution (`cookie`, `accept-language`) | the same jar |

So a hook that renews **only** via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the `Cookie` header is rebuilt from the updated jar. A `maxAge` of `0` deletes, so a hook that signs someone out renders them signed out. If you return an explicit `cookie` header yourself, yours wins.

## Response headers — `responseHeaders`

`responseHeaders` is applied to what this render SENDS — every render-shaped response on both boot paths: `ssr` and `isr` renders, the `spa` shell, and a loader's redirect or 404.

```ts
export const session = defineMiddleware({
  match: { under: '/app' },
  run: async () => ({
    responseHeaders: {
      'x-frame-options': 'DENY',
      'referrer-policy': 'no-referrer',
    },
  }),
})
```

Two boundaries, stated rather than implied:

- **`responseHeaders` act on the RENDER — an isr cache HIT does not re-run the middleware,** so a HIT does not carry the headers the MISS's render produced. For an `isr` page, either set cache-independent headers at the proxy, or accept that only MISS/refresh responses carry them.
- **Prerendered `static` pages never render at request time,** so there is no middleware run to attach headers to. That is the documented proxy recipe: headers on static files belong on whatever serves them.

## A per-request CSP nonce — `cspNonce`

Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, and React's own bootstrap/settle scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.

```ts
import { randomBytes } from 'node:crypto'
import { defineMiddleware } from '@voltro/web/middleware'

export const csp = defineMiddleware({
  match: { under: '/app' },
  run: async () => {
    const nonce = randomBytes(16).toString('base64url')   // fresh per request
    return {
      cspNonce: nonce,
      responseHeaders: {
        'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`,
      },
    }
  },
})
```

- **`isr` + `cspNonce` refuses the render, loudly.** A cached nonce is a lie the browser enforces — the second visitor gets HTML whose nonce the policy header no longer matches. The ways out: `ssr` for nonce'd pages, or a hash-based CSP for `isr`.
- The PPR variant of that question is open until partial prerendering exists; a component for client-injected script tags (a `Script` component) is planned.

## `match` — where it runs

Without a `match`, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.

```ts
import { defineMiddleware } from '@voltro/web/middleware'

export const session = defineMiddleware({
  match: { under: '/app', except: ['/app/public'] },
  run: async (req) => { /* … */ },
})

export const adminTenant = defineMiddleware({
  match: { under: '/admin' },
  run: async () => ({ headers: { 'x-tenant': 'ops' } }),
})
```

| Field | Means |
|---|---|
| `under` | a route subtree — `/app` covers `/app` and everything below it, on segment boundaries (never `/application`). A string or an array. |
| `routes` | exact route patterns, as the router spells them: `/notes/[id]`. |
| `except` | subtrees or patterns to subtract from the two above. |
| `assets` | also run on requests that matched no page. Off by default. |

**Every path here is a ROUTE path, checked against your routes at boot.** A `under: '/ap'` that covers nothing refuses the boot; it does not become a middleware that quietly never fires. An `except` that excludes nothing is reported the same way — it reads as an active rule and is not.

That is the deliberate difference from the URL-pattern shape you may have met elsewhere:

```ts
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'
```

You have to know your own asset layout to write that, it breaks when a build tool renames a directory, and it breaks silently. Voltro's hook runs **after** route matching — framework URLs, anything with a file extension, and anything matching no page are already gone — so an app has never had to know an asset path.

## One middleware per route

**At most one middleware may match a given route.** Two hooks writing one `authorization` header have no defensible winner, so an overlap refuses the boot and names both plus the route:

```
✗ middleware.ts: 1 problem(s)

  • `session` and `admin` both match /app/admin. A route may have at most one
    middleware — two hooks writing one `authorization` header have no defined
    winner. Narrow one with `except`, or fold them into one middleware that
    branches on `req.route`.
```

Declaration order is not a semantic, "most specific wins" would silently drop the broader hook — for a session renewal, that means a subtree stops renewing with nothing red anywhere — and merging needs a per-field rule nobody remembers.

`voltro doctor` reports overlaps and dead matchers before you deploy, and says so out loud when a matcher is built from a variable and it could not read it statically.

## Reaching non-page requests

`assets: true` extends a middleware to requests that matched no page — your files, and paths the router does not serve. It is opt-in and narrow on purpose: there is no render and no rpc call on such a request, so `headers` has nothing to act on and **only `setCookies` takes effect**. The framework's own surface (`/@vite/*`, `/_voltro/*`) is never reachable.

## What it deliberately cannot do

**It cannot redirect or refuse a request.** Authorization belongs on the api, which is the only thing that sees the data; a web-side hook that could refuse would be a second authorization layer beside the real one, and a hook that cannot refuse also cannot be mistaken for a guard. For a login redirect, throw `RedirectError` from the loader.

It also cannot live in `app.config.ts`: that file is imported into the client bundle whenever an api declares `authHeaders`, and a hook that renews a session reaches for an IdP SDK by definition.

## Lifecycle

The file is loaded **once per boot** — it is app code with a stable identity, and re-importing per request would rebuild whatever an IdP client constructs at module level. A failure to import is fatal rather than degrading to "the app has none", and a middleware that throws fails the request: the render must not proceed on the credential the hook was told to replace.

**`voltro dev` therefore RESTARTS when you edit it**, the same way a hard-restart field in `app.config.ts` does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.

It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
