# Routes and render modes

A `route` is a URL + render mode + metadata + offline strategy. Render mode is a per-route property. SEO is enforced by the build, not described in a guide.

`As of 2026-08`. Stable API — semver from here ([Upgrading](/upgrading)).

## The canonical shape

```ts
export const config = defineRoute({
  render:     'isr',                  // static | isr | ssr | stream
  revalidate: { tags: [tag.post] },
  prerender:  () => db.posts.slugs(),
  offline:    'precache',             // precache | runtime | network-only
  hydrate:    'visible',              // idle | visible | interaction | never
  budget:     { js: '40kb' },
  meta:       ({ post }) => ({ title: post.title, description: post.excerpt,
                               og: { image: post.cover }, ld: ld.Article(post) }),
});
```

| Aspect | Rule |
|---|---|
| Projects to | router entry, prerender list, `sw.js` precache/runtime rule, sitemap + RSS row, `<head>` + JSON-LD, per-route budget check |
| Owns | render mode, hydration timing, metadata, offline strategy |
| Never | touch the DB directly, hold business logic, or omit `meta.description` in `site/` — that is a build error |

### `post` — a page URL that also answers `POST`

`defineRoute({ render: 'ssr', …, post: 'unsubscribeOnboarding' })` (`As of 22.6.0`) mounts `POST`
at the page's own path, answered by that action — its policy, idempotency and redirect — with the
URL's query merged over the posted fields (the query wins a shared name: it is the URL the server
minted). The RFC 8058 case: the mail client POSTs `List-Unsubscribe=One-Click` to `/correos/baja?t=…`
and the action receives `{ t, 'List-Unsubscribe': 'One-Click' }`; the page's `GET` stays the confirm
page, and its form can post to itself with no JavaScript. Declare optional input keys for what the
client posts. Not an export name, or on `render: 'static'`: `X_ROUTE_POST_INVALID` at declaration;
naming no registered action: the same code at boot.

### `navigation` — how the client router treats this page

Only on a surface listed in `app.config.ts` `navigation.client` ([Client navigation](/client-navigation));
anywhere else `X_ROUTE_NAVIGATION_INVALID` at boot, and any value but these two at declaration.

| Value | The router | The server, before `load` |
|---|---|---|
| absent | swaps it in on a click; never fetches it early | answers a prefetch `204`, runs nothing |
| `'prefetch'` | also fetches it on hover/focus | answers a prefetch like a visit. Only for a GET that does nothing but render |
| `'document'` | always a real document load, and the page carries no router (no script, no metas, no bytes charged) | answers a soft visit `204` + `x-ultimate-location`, runs nothing; the browser's load runs it once. For a GET that records something: an open, a download, a token consumed — and for a `0b` page |

## The descriptor

`route` is the one primitive whose façade is a **normalized descriptor** rather than a set of projection methods, because a route declares no behaviour to project. It is read by the router, the prerenderer, the sitemap and the budget check — and is designed to be read by `sw.js` generation, which no build calls yet ([#362](https://github.com/developerz-ai/ultimate/issues/362)). The descriptor's job is to hand all of them **one shape** so none of them branches. There is no `.describe()` on a route either — `describePages()` is the one route list.

| Member | Is | Rule |
|---|---|---|
| `kind: 'route'` | the brand | lets the registry reject a non-route export. `isRouteConfig(value)` is the guard |
| `meta(data)` | the `<head>` producer | **always returns a promise.** A synchronous declared `meta` is wrapped, and a `meta` that throws synchronously becomes a rejection — so `await config.meta(data)` is the one way to fail as well as the one way to succeed, and no consumer branches on a thenable |
| `budget` | the per-route limits | **always an object**, `{}` when undeclared, rather than a second undefined-check at every call site |
| `render` `revalidate` `prerender` `offline` `hydrate` `policy` `cache` | the declaration, carried through | unchanged. Optional keys are omitted, never set to `undefined` |
| the whole object | `Object.freeze`d | no consumer can mutate the route another consumer is about to read |

**The always-present `budget` does not weaken the `site/` JS-budget check.** `budget` is always an object, but its *fields* stay optional — so `budget.js === undefined` still means "this route declared no JS budget", and a `site/` route that opts into any `hydrate` other than `never` without one is `X_ROUTE_MODE_INVALID`, exactly as before the normalization. Measuring the declared numbers against the built output is the separate concern in **Budgets**, below.

Validation runs at **module evaluation**. `defineRoute` checks the shape and the mode-local invariants immediately, so a bad route fails at build time rather than on the first request in production.

| Checked | Where | Code |
|---|---|---|
| `offline` present and a known strategy | `defineRoute` | `X_ROUTE_OFFLINE_MISSING` |
| `meta` is a function | `defineRoute` | `X_ROUTE_META_MISSING` |
| mode-local: known `render` and `hydrate`; `static` with a `policy` or a `revalidate`; `isr` with a `policy` or with no trigger; `ssr` with a `prerender`; `cache` on any mode but `ssr`, or a `public`/`immutable` `cache` on a gated route | `defineRoute` | `X_ROUTE_MODE_INVALID` |
| surface-dependent: mode allowed on the surface; `site/` hydration without `budget.js`; `prerender` on a non-prerenderable mode | `registerRoute` | `X_ROUTE_MODE_INVALID` |
| the config came from `defineRoute` and not straight from the author | `registerRoute` | `X_ROUTE_UNNORMALIZED` |
| two files claiming one URL | `registerRoute` | `X_ROUTE_DUPLICATE` |

The split is about what is knowable, not about strictness: everything decidable from the config alone is decided at import; the rest needs the file's surface, which only the route table knows.

## Four render modes

| Mode | Behavior | Use |
|---|---|---|
| `static` | built once, served as a file | marketing, docs |
| `isr` | static + background regen on tag/TTL | catalogs, profiles |
| `ssr` | per-request full render | fresh SEO pages |
| `stream` | static shell flushed instantly, holes streamed | **default for app pages** |

**A served `static` page**, `As of 2026-09`: a matching `If-None-Match` is a bodiless `304`, and
the container renders each page once per locale and answers from memory after — no `load`, no
render — for a page with no dynamic segment, asked for with no query string, that answered 200.
`x dev` renders per request. No other mode is kept this way. What is kept is bounded twice —
1,024 entries and 32 MiB of bodies (`STATIC_MEMO_MAX_BYTES`) — because the request origin is in the
key when the app declares none; past either bound a page is rendered per request.

| Surface | Default | Allowed |
|---|---|---|
| `site/` | `static` | `static`, `isr`, `ssr` |
| `app/` | `stream` | `stream`, `ssr` |
| `api/` | n/a | no rendering at all |

A mode outside a surface's allowed set is a build error, not a runtime fallback. Surfaces and their boundaries: [Project layout](/project-layout).

**`spa` was the fifth mode and was deleted in 6.0.0.** It served `<body><div id="x-root"></div></body>` for the framework's whole history — 200, correct headers, blank page — because `renderSpa` preloaded a `chunks` array no build ever produced and never read the route's component. `render: 'spa'` is now `X_ROUTE_MODE_INVALID` at `defineRoute` time; the migration is one line, `render: 'ssr'`, in [Upgrading](/upgrading).

## What a mode's headers are, and who has the last word

A render mode states the mode's **intent**; `@ultimat3/http`'s `cache-headers` stage makes the final answer. `As of 2026-08-23` that stage REVIEWS a `cache-control` the mode already wrote instead of standing down, which is what made the rule below reachable for a page at all.

| The request | What it gets, whatever the mode wrote |
|---|---|
| anonymous | the mode's own intent, plus the key dimensions it forgot: `vary: accept-language, cookie, x-timezone` |
| carrying an identity | `private, max-age=0` — never `public`, never `s-maxage`, and never bare freshness: `max-age`, `must-revalidate` or `proxy-revalidate` without `private`/`no-store` is an offer a shared cache may take (RFC 9111 §3), so it is rewritten too. `meta.auth` is only `'public' \| 'required'`, so the page that greets a signed-in visitor by name is a `'public'` route whose own header offered it to a CDN for 30 seconds |
| a content-addressed URL (`immutable`) | left alone. `immutable` asserts the body is a function of the URL, which is what an island chunk is |

### A route's own `cache`, on `ssr`

`As of 2026-09-24`. An ungated `ssr` page is offered to a CDN for 30 seconds
(`public, max-age=0, s-maxage=30, stale-while-revalidate=300`). A page can be public and still
personal: a recipient landing addressed by a capability token in its path, or a verification page
that must never answer with an old result. `cache` replaces that default:

```ts
export const config = defineRoute({
  render: 'ssr',
  offline: 'network-only',
  cache: 'no-store',                  // or a CacheHint: { mode: 'public', sMaxAgeSeconds: 5 }
  meta: ({ t }) => ({ title: t('verify.title'), description: t('verify.description') }),
});
```

| | |
|---|---|
| `'no-store'` | `private, no-store`, the answer a gated page already gets. It carries the principal scope like every private document |
| a `CacheHint` | `@ultimat3/http`'s own type, without `tags`, emitted by `cacheControl()`. Its `vary` joins the defaults. The router checks its ages at mount (`X_CONFIG_INVALID`) |
| which modes | `ssr` only. `static` and `isr` are hashed or TTL'd documents whose headers are the mode itself. A `stream` is always `private, no-store`. On any of them `cache` is `X_ROUTE_MODE_INVALID` |
| a gated route | may narrow its cache (`'no-store'`, `{ mode: 'private', … }`), never widen it. `public` or `immutable` is `X_ROUTE_MODE_INVALID` |
| a signed-in visitor | the table above still applies. A declared `public` becomes `private, max-age=0` for them |

**A shared document is rendered as nobody.** `As of 2026-11-02`. `isr` and `static` run `load`, `meta` and the page with the anonymous actor, services rebuilt for it, the app's default zone, `ctx.url` at the document's own address, and no inbound headers at all, `ctx.ip` and `ctx.peer` `null` (`withAnonymousRequest`, `@ultimat3/http`). Whatever a stored page holds is answered to every later visitor, with public cache headers, so it varies on what its key carries: the URL and the locale. `useRequestHeader()` answers `null`; `setRedirect()` still works. A page that varies per visitor is `ssr`.

**No declared origin: an `isr` entry is keyed by the request's origin too.** `canonical` and `og:url` are absolute against the declared origin (`APP_URL`, `SITE_ORIGIN`, `site.origin`) or, with none, the request's own, so without this a first request with a foreign `Host` wrote that host into a page later visitors were answered. The origin rides the reserved `__x_origin` parameter. With a declared origin (http or https) there is no such parameter: one entry, and `load` is handed `url` at the declared origin. Without one, a process stores pages for 16 request origins (`ISR_MAX_REQUEST_ORIGINS`); any other `Host` is answered uncached, `private, no-store`, and evicts nothing (`isr.origin.over_cap`, logged once: declare `APP_URL`).

**An `isr` entry is keyed by the negotiated locale.** The store key is `isrKey(url, locale, origin?)` — the path in its one spelling (below), the reserved `__x_locale` parameter, then the query the route declared (`revalidate.query`, below) with its params sorted. Without it, an app shipping two locales served visitor 2 the document rendered for visitor 1, for the whole TTL, and told the CDN to do the same. The time zone is deliberately **not** a dimension — a locale set is declared and bounded, a zone list is not — so a date on an `isr` page belongs in a zone the page itself names, or the page belongs in `ssr`.

**An `isr` page is one entry however its URL is spelled.** The router answers `/blog//abc/`, `/blog/%61bc` and `/blog/abc` with the same route and the same `slug`, so the store keys on the address it **matched**, never on the pathname as written: empty segments and the trailing slash dropped, each segment decoded and re-encoded one way (`@ultimat3/http`'s `canonicalPath`, built from the functions the router itself segments and decodes with). `As of 2026-10-11`.

| Request | Stored under | Why |
|---|---|---|
| `/blog/abc`, `/blog/abc/`, `/blog//abc`, `//blog/abc`, `/blog/%61bc`, `/blog/./abc` | `/blog/abc` | one route, one `slug` |
| `/blog/caf%c3%a9`, `/blog/caf%C3%A9`, `/blog/café` | `/blog/caf%C3%A9` | hex case and raw non-ASCII are spellings |
| `/blog/a%2fb`, `/blog/a%2Fb` | `/blog/a%2Fb` | an encoded slash is part of the segment (`slug` is `a/b`), never a separator: `/blog/a/b` is another address, and a 404 for `/blog/[slug]` |
| `/en/blog/abc`, `/en//blog/%61bc/` | `/en/blog/abc` | the locale prefix is kept; a locale's home is `/en/` |
| `/shop/blog/abc` under `basePath: '/shop'` | `/blog/abc` | the route table has never heard of the mount |

- **`load`, `meta` and the page are handed that same address** as `url` (base path included), so the document's `canonical` is `/blog/abc` whichever spelling arrived first. A `static` page is rendered at its address too, as the export writes it; `ssr` and `stream` keep the URL as the visitor wrote it.
- **Nothing is redirected**, and no status changes: a spelling the router answered with a 200 is still a 200, with the canonical page's `cache-control`, purge keys and `canonical` link. Redirect at the ingress if you want one URL in a browser's address bar.
- **A key no route owns is never stored.** An entry takes its `ttl` and its tags from its route, so stored without one it was fresh for ever and on no tag edge. `isrController().serve()` renders it for the request that asked, `private, no-store`, and logs `isr.key.unowned` once. The same holds for a request the router matched as one route while its address reads back as another (`//en/x` is the `/[lang]/x` route; `/en/x` is the `/x` route in English): answered, never stored.
- Releases through 27.8.0 keyed on the raw pathname ([Known Gaps](/known-gaps)).

**A locale-prefixed `isr` page is its route's page.** The key keeps the prefix — `/en/blog/a?__x_locale=en` is the document rendered for that URL — and the route it belongs to is found on the path with the routed prefix removed (`unlocalizedPath`), as the router matches it. So `/en/blog/a` takes `/blog/:slug`'s `revalidate.ttl` (`s-maxage` included), joins its `revalidate.tags`, and is regenerated in its own locale: a tag bust or the TTL expires every locale's copy of a page, stored 404s included. A page whose truth changes with the clock (`publishedAt <= now`) is written by nothing when the instant passes, so no tag is busted — it needs a `ttl`, and the `ttl` alone is enough in every locale. Releases through 27.6.1 matched no route for a prefixed page ([Known Gaps](/known-gaps)): tag-only `s-maxage=60`, no tag edge, fresh for the life of the process.

**An `isr` regeneration holds its page for at most 30 s** (`DEFAULT_ISR_REGENERATE_DEADLINE_MS`; `isrController({ regenerateDeadlineMs })`, a whole number ≥ 1) `As of 2026-10-02`. A render that never settles used to pin its page for the life of the process: a missed page hung every later request, a stale one was served stale forever. Past the deadline the next request renders again; the hung render is not cancelled, and its late result is dropped if a newer one has started. A stored path takes its TTL and tags from the **most specific** route that matches it — segment by segment, static beats `:param` beats `*catch-all`, as the request router resolves it — so `/docs/7` is `/docs/:id`'s, never `/docs/*path`'s. A zero-length `ttl` (`'0s'`, `0`) is no trigger at all: `X_ROUTE_MODE_INVALID`.

### `revalidate` — what an invalidation does, how old a copy may be, what the page varies on

`As of 2026-10-10`. Three optional keys beside `tags` and `ttl`, each refused at `defineRoute` when the controller could not act on it (`X_ROUTE_MODE_INVALID`).

```ts
import { tag } from '@ultimat3/cache';
import { defineRoute } from '@ultimat3/render';

declare const posts: { page(n: number): Promise<{ readonly titles: readonly string[] }> };

export const config = defineRoute({
  render: 'isr',
  revalidate: {
    tags: [tag('post')],
    ttl: '10m',
    onInvalidate: 'purge', // 'stale' (default) | 'purge'
    maxStale: '1h',        // past ttl + 1h a copy is not an answer; 0 (purge's default) is never
    query: ['page'],       // the only query parameters in the key; [] for none
  },
  offline: 'runtime',
  load: ({ url }) => posts.page(Number(new URL(url).searchParams.get('page') ?? '1')),
  meta: ({ url }) => ({ title: 'Blog', description: 'Every post, newest first.', canonical: url }),
});
```

| Key | Default | What it decides |
|---|---|---|
| `onInvalidate: 'stale'` | yes | a tag bust marks the stored page stale; the next request is answered **that copy, however old**, while a fresh one renders behind it (`x-ultimate-isr: stale`). Right for a price list |
| `onInvalidate: 'purge'` | — | a tag bust **deletes** every stored page of the route carrying the tag — each locale, each keyed query, stored 404s included — and evicts any render of them in flight, so the next request renders and waits. For content that must come **down** (a withdrawn article, a corrected legal text). Needs `tags` **and `ttl`** (`X_ROUTE_MODE_INVALID` without either). The response carries no `stale-while-revalidate`, and its `s-maxage` is what is **left** of the `ttl` |
| `maxStale` | unbounded; **`0` under `'purge'`** | how long **past its `ttl`** a stored page may still be answered stale: a duration, or `0` for never. Older, it is dropped and the request waits for a render. Also the `stale-while-revalidate` a shared cache is told (a day when omitted, absent at `0`). Needs `ttl` |
| `query` | `[]`, none | the query parameters the page varies on. Only these are in the stored page's key **and** in the `url` / `query` that `load`, `meta` and the page receive; one value each (the first), sorted |

**An `isr` route keys on no query unless it says so.** `As of 2026-11-02`. `/blog?x=1`, `/blog?x=2` and `/blog?utm_source=mail` are one render and one stored page, so no visitor can spend the origin's renders or push real pages out of the bounded store (it holds 1,000), and `utm_*` / `fbclid` / `gclid` do not split a page. An `isr` document is shared: what it varies on is the route's to declare.

**The default store is bounded twice, and keeps what is read.** `As of 2026-11-09`. `memoryIsrStore({ maxEntries, maxBytes })`: 1,000 pages (`DEFAULT_ISR_MAX_ENTRIES`) and 256 MiB of `html.length` (`DEFAULT_ISR_MAX_BYTES`), whichever is reached first. Least recently **used** goes first — a hit moves a page to the back, so a home page read on every request outlives cold pages generated after it; a stale mark keeps its place. A page larger than the whole byte cap is not stored and is rendered per request.

**Declare every parameter the page reads.** A page that reads `?currency=` and does not list it is ONE stored document, the first visitor's, answered to every other currency. Two nets, both best-effort: `x verify` reads each `isr` route module for `query.<name>`, `query['<name>']` and `searchParams.get('<name>')` and warns for a name the declaration omits (a read made through a helper in another file is not seen); and outside production, the `query` a page component receives warns once (`isr.query.unkeyed-read`) when the page reads a parameter the route did not declare. `load` and `meta` read the narrowed `url`, where an undeclared parameter is simply absent.

**Nothing is redirected.** `/blog?utm_source=mail` is answered with the `/blog` page: `load` and `meta` are given the narrowed URL, so `canonical: url` is `/blog` and a parameter outside the key cannot change the document stored under it. A declared parameter's **values** are still the visitor's — `?page=999999` is a key — so `load` answers `withStatus(404, …)` for one that names nothing, and the bounded store evicts the rest.

#### How fast a purge takes a page down

"The bust" below is the moment `invalidateTags` reaches its ISR step — after the read tiers (`redis`, `lru`, `request-memo`) cleared, so the re-render reads fresh rows. An action's `cache.invalidates` runs after its commit and is **not awaited by the action's response**, so the response can precede the bust by those tiers' latency.

| Deployment | A request after the bust gets | Bound |
|---|---|---|
| one replica | a fresh render — never the stored page, never a render that began before the bust (its flight is evicted; a request that had joined it renders again) | the next request |
| several replicas, per-replica stores, bus healthy | the same, on each replica **once it has received the broadcast** | broadcast delivery, typically milliseconds; inside it a peer still answers its copy as a hit |
| several replicas, per-replica stores, **broadcast lost** | a replica that never heard the bust answers its copy as a fresh hit until that copy's `ttl`, then renders (nothing stale past it: `maxStale` is `0` under `'purge'`) | the route's `ttl`, at the origin **and** at an edge in front (`s-maxage` is the ttl's remainder) |
| several replicas over one `runtime.isrStore` | a fresh render on every replica: the page is deleted from the one store, and a replica that began a render before the bust cannot write it back (`IsrStore.tagFence`) | the next request, bus or no bus |

Order inside one bust: read tiers (farthest first) → this origin's ISR pages → the broadcast to peers → the `cdn` tier **last**. Each replica that receives the broadcast purges the edge again after its own delete, so a peer that answered the old page inside the broadcast window is cleared from the edge too. A render that read its rows before the bust is answered to its own request `private, no-store`, with no purge key, and is never stored.

**Finish a rollout before relying on `'purge'`.** A replica still on a release before this one reads `onInvalidate` as nothing: it marks the page stale and answers it once more, with `stale-while-revalidate=86400`.

**Past `maxStale` the copy is dropped before the re-render**, so a render that fails there answers its 5xx rather than the old page — that is what the bound means. Ages are measured by each replica's own clock against the entry's `generatedAt`; over a shared store, skew between replicas moves `ttl` and `maxStale` by that skew.

**A shared store must be able to refuse a write.** `runtime.isrStore` under `'purge'` needs `IsrStore.tagFence` — `sample(entities)` before a render reads its rows, `bump(entities)` on a bust, and an **atomic** `setIfCurrent(entry, entities, sampled)` (a transaction, a script, a conditional put) that refuses a write whose reading moved. The generation is per tag *entity* (`post` for `post:1`), so an unrelated row's bust costs one extra render and never a stale page. `memoryIsrStore` implements it; a store without it is refused at boot when any route purges (`X_ROUTE_MODE_INVALID`, naming the route). A bust also asks the store which keys it holds under the tags, by the route table, as well as the invalidation graph — the graph only knows the pages this process rendered. It matches on the **route's** tags: a `post:1` bust reaches every stored page of a route tagged `post`.

**One store for every replica: `storageIsrStore`.** `As of 2026-11-09`. `memoryIsrStore` is one cache per process — three web replicas render a page three times and hold three copies. `runtime.isrStore = storageIsrStore({ disk: disk('isr') })` keeps pages (`isr/page/…`) and tag-fence tokens (`isr/fence/…`) as objects on a `@ultimat3/storage` disk every replica reads. A bust writes a fresh token per tag entity; a write checks the reading, writes, then checks again and deletes its own page if a bust landed in between — a disk has no compare-and-put, and the bust lists the store only after its token is written, so no page outlives a bust its render predates. Bounded across replicas by `maxEntries` and `maxBytes` (the defaults above), swept every `sweepEvery` writes (32) per process, **oldest write first** — a read never writes, so it is not LRU. Every store call is awaited, so a custom store may answer with a promise (`IsrAnswer<T>`).

**What `isr` never stores.** What it does store was rendered as nobody (above); a per-visitor answer has no place in one, so that page is `ssr`.

| Render | Stored | The visitor gets |
|---|---|---|
| a 5xx (`withStatus(503, …)` — "the read failed") | never; never over a good copy | the last good page, stale, while one exists (`isr.render.unstored` is logged); otherwise the 5xx itself, `private, no-store`. For one second (`DEFAULT_ISR_FAILURE_COOLDOWN_MS`; `isrController({ failureCooldownMs })`) requests for that page are answered the same failure, or the same stale page, instead of each rendering again |
| `noStore(data)` from `load` | never; a stored copy of the page is dropped | that render, `private, no-store` |
| a `load` or page that throws | never | the last good page, stale (`isr.regenerate.failed`); with none, the error page |
| a render invalidated while it ran | never | the request that started it: that render, `private, no-store`; any other request: a render of its own |
| a redirect (`setRedirect()`) | never | the redirect |
| a 2xx or 4xx | yes — a 404 is a stored page | it, for the `ttl`. A 4xx is never offered to a shared cache (`no-store`, no purge key): the pipeline's rule for every refusal |

`noStore` is `@ultimat3/render`'s, a mark on the data like `withStatus` and composable with it (`noStore(withStatus(404, data))`). `isr` only — an `ssr` page's cache is its route's `cache` key.


**A page with an island is admitted to its own CSP by hash.** The hydration runtime is an inline `<script type="module">`, and `script-src` admits it as one of the seven bodies `HYDRATE_RUNTIME_BODIES` enumerates, hashed at boot. Hashes and not a nonce because a `render: 'static'` page is a file on disk. The theme boot script is the second body the boot hashes, from `theme.defaultMode` (`As of 20.2.0`; an app writes neither the tag nor the hash). The speculation rules are the third (`As of 2026-09`, [Client navigation](/client-navigation)). An app that adds its own inline script beyond those has to admit it the same way, at module scope in a file under `apps/*/`: `configureHttp({ security: { csp: { extend: { 'script-src': [cspHashSource(body)] } } } })`. The boot's own hashes are **merged per directive**, never replaced, so admitting a CDN source does not evict the hydration runtime's hash and lock every island out of the page.

## One declaration of the vocabulary, at tier 0

`As of 2026-08` the three closed sets a route is declared in — `RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES` — are declared **once**, in `@ultimat3/core`, with each union derived from its array so the pair cannot disagree.

| Import it from | When |
|---|---|
| `@ultimat3/core` | anywhere. It is tier 0, so no package is below it |
| `@ultimat3/render` | you are already importing `defineRoute` — it re-exports all three, types and arrays |
| `@ultimat3/http` · `@ultimat3/seo` · `@ultimat3/manifest` · `@ultimat3/pwa` | that package's own signatures take one, so it re-exports what it takes |

**Re-export, never restate.** Six packages each kept a hand-written copy until 2026-08 — 14 declarations in all — because imports only go down tiers and copying was the move available. `'spa'` was then deleted from one of them and five went on admitting it under a green project-wide typecheck; `@ultimat3/pwa`'s copy mapped it to `cache-first`, the one strategy that gives an `app/` route a **shared** cache entry. `bun run scripts/render-modes.ts --json` refuses a second declaration, matching on the literal set rather than the name — the copy that did the damage was called `PwaRenderMode`.

## Why `stream` is the app default

An authed page needs fresh data and fast first paint. `ssr` gives freshness and a slow TTFB — the whole page waits for the slowest query. `stream` gives both halves: shell now, data as it resolves. A page whose body belongs in the browser declares an `island({ src })`, budgeted in real bytes; there is no client-side-only mode and no client router.

`x g route`, `x g resource` and `x new`'s dashboard write `render: 'stream'` on `app/`. A `stream` page with no hole registers and pays nothing: it is one flush.

```tsx
import { Hole } from '@ultimat3/render';
import type { JSX } from 'solid-js';

// Yours: the components and the two reads.
type View = (props: { children?: JSX.Element; rows?: readonly unknown[] }) => JSX.Element;
declare const Layout: View, Header: View, Stats: View, Feed: View;
declare const StatsSkeleton: View, StatsUnavailable: View, FeedSkeleton: View;
declare const statsQuery: (input: object) => Promise<readonly unknown[]>;
declare const feedQuery: (input: object) => Promise<readonly unknown[]>;

// A thunk: it starts after the first flush, in the request's context. `useContext()`, `useT()` and
// the actor all work in it. A promise written straight into the JSX has started already.
const readStats = async () => <Stats rows={await statsQuery({})} />;
const readFeed = async () => <Feed rows={await feedQuery({})} />;

export function Page() {
  return (
    <Layout>
      <Header />                                {/* in the first flush */}
      <Hole fallback={<StatsSkeleton />} error={<StatsUnavailable />}>
        {readStats}                             {/* streamed when the query resolves */}
      </Hole>
      <Hole fallback={<FeedSkeleton />}>
        {readFeed}                              {/* independent hole, independent flush */}
      </Hole>
    </Layout>
  );
}
```

Solid's fine-grained reactivity is what makes this cheap:

| Property | Consequence |
|---|---|
| No VDOM | streamed HTML is patched into place; nothing re-renders to reconcile |
| Signals, not component re-execution | a resolved hole updates the exact DOM nodes bound to it |
| **No hydration pass over the shell** | the static shell costs 0 hydration work — a `<Hole>` hydrates only its own island, only when its `hydrate` timing fires |
| Compile-time templates | the shell's JS is markup-shaped, not a component tree to replay |

In a VDOM framework a streaming shell still pays for hydrating the whole tree, which is why streaming there buys TTFB but not TBT. Here it buys both.

### What a hole does

| Fact | Rule |
|---|---|
| `fallback` | markup in the first flush. Rendered at shell time; it is not weighed by `budgets`, so keep islands out of it |
| `error` | shown when the hole's children throw or miss the 15 s deadline (`DEFAULT_HOLE_TIMEOUT_MS`). Rendered at shell time. Omitted: an empty hidden element. Either way the document closes, `render.stream.hole_rejected` or `render.stream.hole_deadline` is logged and the error monitor hears it |
| Other modes, a test, `budgets` | no second flush exists, so `<Hole>` is its children, awaited inline, and a throw is the render's throw. `renderRoute` shows the filled page, and a hole's island is weighed |
| Nested | the inner hole renders inline inside the outer one: the set of holes is fixed at the first flush |
| Status, redirects, cookies | decided before the first flush. `withStatus`, `setRedirect` and `setCookie` stay `load`'s: inside a hole the last two are `X_STREAM_ALREADY_FLUSHED`, shown as the hole's error markup and reported. A throw in a hole is its error markup |
| Cancellation | `useContext().signal` inside a hole is the hole's own: aborted at its deadline and when the client leaves. Pass it on: `fetch(url, { signal: useContext().signal })` |
| A long wait | an empty comment every second while a hole is open keeps the socket's idle timer (`requestTimeoutMs` + 2 s) from cutting the stream |
| Async component | `X_ROUTE_ASYNC_PAGE` applies to `Page`, never to what renders under a hole |
| A hole's island | hydrates from its chunk: the chunk carries the hydration runtime after its reveal, and the realtime boot if this is the first island that needs it |
| A shell island while a hole is open | live: the runtime is `<script type="module" async>`, so it does not wait for parsing to end. With a realtime boot in the document it stays deferred ([Known gaps](/known-gaps)) |
| Client router | a soft visit reads the whole answer before it swaps ([Known gaps](/known-gaps)); every reveal still runs |
| CSP | every chunk's inline script is a body `inlineScriptSources()` already hashes |

Solid's own boundary component is not this: it throws outside a Solid renderer, and the server JSX factory is inert by design.

Independent holes each resolve their own [queries](/queries-and-live-queries); the tier-1 request memo means three holes reading the same query hit Postgres once.

## `hydrate` timings

| Value | Wakes when | Use |
|---|---|---|
| `idle` | after first paint, on `requestIdleCallback`. A click made before the island mounts is captured and replayed on the same element, found by its path, as `interaction` does (21.0.0; the click was lost before). A keyboard or scripted click (`detail: 0`) is not hit-tested at `(0, 0)` | default in `app/` — above-the-fold interactivity |
| `visible` | the island intersects the viewport | below-the-fold lists, comment threads, charts |
| `interaction` | first pointer/focus/key event on the island | menus, modals, popovers, dropdowns |
| `never` | not at all — server HTML is final | static islands, rendered markdown, badges |

Hydration is per-island, never per-page. A blown `budget.js` is a build failure, so islands cannot quietly accumulate.

**One kind of island is hidden until it mounts, whatever the strategy** (`As of 2026-10`): on a signed-in page with a sync node, an island that reads realtime's record store or outbox is held (`data-x-hold`, `visibility:hidden`, its box kept), booted at once, and revealed when it mounts or after 3 s. An island that only follows a channel's events, a roster or the connection is not, and with scripting off none is. The rule and the list of names: [Realtime](/realtime#which-islands-wait-for-the-pages-restore).

## Budgets

Per route, and one key: `budget.js`, in bytes. `budget.lcp` is deleted — nothing in the build observes a paint, so it was declared and never weighed.

**`budget.js` counts raw minified bytes on disk — uncompressed**, `As of 2026-09-05`: what the
browser parses and executes, never the gzip or brotli size it transfers. A 350 kB library is 350 kB
against the budget however well it compresses, because the number bounds execution cost, not
bandwidth. `measureDocumentJs` in `packages/cli/src/budgets.ts` is the one reader, and the
`X_BUDGET_EXCEEDED` line says `(minified, uncompressed)` so the unit is never inferred.

| Check | Source of truth |
|---|---|
| Per-route JS bytes | `budget.js` on the route; measured from the real bundle graph |
| Lighthouse SEO + a11y scores | Score thresholds are not implemented or enforced (`As of 2026-10`) |
| Precache size | total `sw.js` precache set, see [PWA and offline](/pwa-and-offline) |
| Regression | budgets ratchet — the recorded baseline can tighten, never loosen silently |

A blown budget is a **build failure**, not a warning:

```
x verify
  ✓ typecheck            ✓ import boundaries      ✓ migration drift
  ✓ lint                 ✓ tests (6 types)        ✓ contract diff
  ✗ budgets
      site/pricing   js 61kb > 40kb   (chart.js via shared/ui/button.tsx)
```

Failures name the *cause* — the transitive import that added the bytes — because "bundle too big" without a chain is not an instruction. `x verify --json` emits the same content machine-readably.

## SEO enforcement

| Concern | Enforcement |
|---|---|
| `meta.title` missing on an indexable route | build error `X_SEO_META_MISSING`, `cause` naming `title` and the file |
| `meta.description` missing on an indexable route | the **same** code, `X_SEO_META_MISSING`, naming `description` — one code for both fields, with `fix: add description to meta in <file>` |
| Title over 60 chars, description over 160 | build error `X_SEO_META_TOO_LONG`, with the measured length. A **too-short** description is not checked and no minimum is exported `As of 2026-08`: a 10-character description passes the gate. `DESCRIPTION_MIN_LENGTH` used to be exported and read by no validator, which is the shape a length bound must not ship in — `validateMeta` enforces every bound `@ultimat3/seo` exports, pinned by a test |
| Duplicate title/description across routes | build error — duplicate meta is a ranking bug, not a style issue |
| `og.image` missing on a shareable route | build error; the generated fallback OG image must be opted into explicitly |
| Broken internal link | build error, resolved against the route table |
| Missing `alt` on an `<Image>` | build error |
| Canonical URL | emitted for every route from the route table; never hand-written |
| `robots` / `noindex` | a route-level field, so "we shipped staging to Google" is impossible without editing the route |

A convention that isn't a build error doesn't exist. SEO is the archetype — every SEO regression in history was a documented convention someone forgot.

## Typed JSON-LD

```ts
meta: ({ post }) => ({ /* ... */ ld: ld.Article(post) })
```

Helpers, typed against schema.org shapes:

| Helper | For |
|---|---|
| `ld.Article` | posts, news, docs pages |
| `ld.Product` | catalog and detail pages with price + availability |
| `ld.Organization` | the site's own identity, emitted once |
| `ld.BreadcrumbList` | derived from the route's path segments |
| `ld.FAQPage` | Q&A blocks |
| `ld.SoftwareApplication` | app landing pages |
| `ld.Event` | dated, located events |
| `ld.Recipe` | ingredient + step content |

A missing required property is a **type error**, not a Rich Results Test failure discovered next week. Output is a single `<script type="application/ld+json">`, deduped per page.

## Generated from the route table

| Artifact | Derived from | Notes |
|---|---|---|
| `sitemap.xml` | all indexable routes + `prerender()` results | one `<url>` per page per locale with `xhtml:link` alternates; `lastmod` and the locales a page is listed in from its `prerender()` entry (`{ params, lastmod: row.updatedAt, locales }`); an index of named sections by `seo.sitemap.sections`, else auto-split at 50k URLs — [SEO](/seo#robotstxt-and-sitemapxml) |
| `robots.txt` | `seo.robots.disallow` and `seo.robots.groups` in `app.config.ts` | sitemap reference included; anything but `ULTIMATE_ENV=production` is `Disallow: /` |
| `rss.xml` / `atom.xml` / `feed.json` | routes tagged as feed items | one declaration, three formats |
| `llms.txt` | `site/` route titles + descriptions | machine-readable site summary for agents: `GET /llms.txt` and the static export, `seo.llms: false` to opt out ([SEO](/seo)) |
| `404` / `500` | required routes | missing one is a build error |

Nothing here is a plugin. Deleting a route removes it from the sitemap in the same build.

## i18n routing

| Feature | Behavior |
|---|---|
| Locale routing | `/`, `/en/`, `/de/` from the locales `defineCatalogs()` declared — no per-route wiring. The prefix is stripped before matching; `/<default>/x` 301s to `/x`; an unrouted prefix is a plain 404 |
| `site/` locale | the URL alone: unprefixed is always the default locale, never negotiated; no `Vary: accept-language` |
| `hreflang` | full reciprocal set emitted per route, BCP 47 region form, including `x-default`; absolute against the public origin |
| Per-locale static output | each `site/` page is prerendered once per locale (`index.html`, `en/index.html`); no client-side locale swap on `site/` |
| Missing key | renders `⟦key⟧` in dev, **fails `x verify`** in CI |
| Numbers / dates / money | `Intl.*` with an explicit IANA `timeZone` and ISO currency; never a hand-rolled format |
| Localized metadata | `meta` receives `locale`, `localizedPath(locale)` and `alternates` |

See [I18n](/i18n), [Timezones and dates](/timezones-and-dates), [Money](/money).

## Image pipeline

```tsx
<Image src={post.cover} alt={post.title} sizes="(max-width: 700px) 100vw, 700px" priority />
```

One `<Image>` shape, one capability contract, stated once — in [`docs/idea/07-rendering-seo.md` → Image pipeline](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/07-rendering-seo.md#image-pipeline): variants, `srcset`, inlined dimensions, the blur placeholder, the `@ultimat3/core` runtime, and the `ImageTransformDriver` seam AVIF/WebP variants come from. Restating it here would let the two copies drift.

## A route that answers a status

`As of 2026-09-07`. A loader hands its data through `withStatus(status, data)` and the page
renders as it always did — the route's own component, inside the app's own shell — with that
status on the response.

```ts
import { defineRoute, withStatus } from '@ultimat3/render';

export const config = defineRoute({
  render: 'ssr',
  offline: 'runtime',
  load: async ({ params }) => {
    const host = await hostList({ id: params.host });
    return host === undefined
      ? withStatus(404, { kind: 'missing' as const, hostId: params.host })
      : { kind: 'page' as const, host };
  },
  meta: ({ data, t }) => ({ title: data.kind === 'missing' ? t('notFound') : data.host.label }),
});
```

| | |
|---|---|
| what it does | records the status against **that object**; hands the same object back, so `load`'s return type, `meta`'s `data` and the page's `props.data` are untouched |
| which statuses | any 2xx, 4xx or 5xx. A 3xx is `X_ROUTE_STATUS_INVALID` — a redirect is a `Location` and no body, which is `setRedirect()` from `load` (below). Out of 200–599, or not whole, is refused where it is written, never as a `RangeError` at `new Response` |
| SEO | a 4xx or 5xx is `<meta name="robots" content="noindex">` **by construction** — the descriptor's `meta` applies it after the route's own `meta` ran, so a page that does not exist is never indexed however `meta` was written. `follow` and the rest stay the author's; a 200 hands `meta`'s object back untouched |
| every mode | `ssr`, `stream`, `static`-served and `isr` all answer it; an `isr` entry stores a 2xx or 4xx status beside the HTML and serves it on every hit, stale copies included; a 5xx is answered and never stored |
| the static export | a file has no status. `x build --target static` writes the document whatever the loader said, and the build's measurer — which renders an `app/` route with `params: {}` to weigh it — never fails on a loader answering 404: the status is a fact about the data, not a throw |
| an untouched app | reads 200 everywhere it did, byte for byte — nothing asks unless a loader answered |

**A redirect is `setRedirect()`, from `load`.** `As of 2026-09-24`. It is the same call an action's
handler uses, from `@ultimat3/http`. On the page path, the request answers with that `Location`
and status (default 303, and 302/307/308 when named) and no document is rendered:

```ts
import { setRedirect } from '@ultimat3/http';

load: async ({ params }) => {
  await recordOpen(params.token);            // a tracking pixel, a download link
  setRedirect('/pixel.gif', 302);
  return { params };                          // never rendered
},
```

With no declared `cache`, the redirect is `private, no-store`: the loader decided it for this one
request, and a cached copy would answer later requests without running the loader. A route with a
declared `cache` gets that instead. `withStatus` still refuses a 3xx, because a rendered document
has no `Location`.

**A refused page is `withStatus(403, data)`.** The same call answers a denial rendered in the
shell — an `@ultimat3/admin` mount whose `guardedPage()` returned `AdminPageDenied` asks the same
`decideAll` in its `load` and returns `withStatus(403, data)` (the admin README shows the load).

**Not a throw, deliberately.** Throwing is the other 404 and it is still there: a route the table
does not have, or a loader that throws `X_NOT_FOUND`, gets the framework's error page —
outside the app's shell, no sidebar, no session list. Measured in one adopter's app on 2026-09-07,
`/fleet/nope` rendered the right page inside the shell and answered 200, because the only way to
the status was the way that lost the shell. The two answers stay distinct: **the table lacks the
route** → throw → error page; **the route exists and says "not found"** → `withStatus` → its own page.

## Error pages

A browser that hits a failure in a production process gets the framework's error page — the
status, the code, the request id, and nothing off the throwable. Until `As of 2026-08-23` a
production browser got `problem+json` with the internal `cause` and the author-facing `fix:` in it.

| status | `x dev` | production |
|---|---|---|
| 4xx, app file present | the app's file | the app's file |
| 4xx, no app file | the overlay — cause, fix, stack | the framework's page |
| 5xx | the overlay, always | the app's file, else the framework's page |

A 4xx in dev is the page a visitor will see, so it is the one the author sees; a 5xx is a defect,
and the overlay is how dev debugs one. An agent (`accept: application/json`) gets `problem+json`
in both.

| | |
|---|---|
| a page that exists and answers 404 | not this page at all — `withStatus(404, data)` in the loader renders the route's own component with the status ([above](#a-route-that-answers-a-status)); the error page is for a **throw** |
| override, one per status | `apps/web/site/errors/<status>.html` — served byte for byte, exact status match only; read per request under `x dev` (drop a file into the running server), once per status in a container |
| static export | `404.html` is written by `x build --target static`, the app's file if present |
| copy | the `errors.*` keys of the i18n catalog — an app translates or overrides by declaring them |
| footer | links to the Ultimate repository and developerz.ai; an override replaces the whole page |

## Rules

- `render` and `offline` are declared on the route or defaulted by surface. Never inferred at runtime.
- `revalidate.tags` are typed handles from the tag graph — an unknown tag is a compile error ([Caching and invalidation](/caching-and-invalidation)).
- Routes never touch the DB directly. Data comes from a `query`.
- A route holds no business logic. That is a service, behind an [action](/actions).
- `prerender()` must be deterministic and bounded — it is a build input.
- Never hand-write a canonical URL, a sitemap entry, or `sw.js`.
- A URL off data inside an island goes through `@ultimat3/ui`'s `Link`, or `safeUrl(url, 'href')` from `@ultimat3/core` at the call site. The island's server shell is screened like every page (a `javascript:` href is not emitted), but once it mounts Solid's compiled setter writes `<a href={row.url}>` as given; only the app's CSP stands between a looser policy and a live `javascript:` link. `As of 2026-11-09` there is no build check for it.
