-
Notifications
You must be signed in to change notification settings - Fork 0
Client Navigation
Moving between pages of an opted-in surface without a full document load: the router fetches the next page the server renders anyway and swaps it into the same tab. The tab keeps its islands that did not change, its socket, its page store and its scroll history, so an app of server pages feels like an installed one.
Not a render mode. Every page is still rendered by its own route, in its own mode (ssr,
stream, static, isr), behind its own policy and cache headers. The router only decides what
the browser does with the answer. Without the script (scripting off, or a link marked
data-x-reload), the same markup is an ordinary multi-page app. Why this and not a client-rendered
mode: docs/history/render.md.
One rule over everything below: nothing the router sends is sent twice, and nothing it guesses runs a route that did not ask for it. The server enforces both, before any app code runs.
// app.config.ts
export const config = defineConfig({
name: 'notificado',
navigation: { client: ['app'] },
});navigation.client |
Effect |
|---|---|
[] (default) |
every navigation is a full load; no surface ships a router byte |
['app'] |
app/ pages navigate softly; site/ stays 0kb of JS |
['site', 'app'] |
both. Crossing from one surface to the other is still a full load |
api and shared render no documents and are refused (X_CONFIG_INVALID).
A page may then say how the router treats it, with the route's twelfth key:
export const config = defineRoute({ render: 'ssr', …, navigation: 'prefetch' });navigation |
The router | The server, before load
|
|---|---|---|
| absent | swaps the page in on a click; never fetches it early | a prefetch gets an empty 204
|
'prefetch' |
also fetches it on hover/focus | a prefetch is answered like a visit |
'document' |
always a real document load; the page itself carries no router (As of 22.8.1): no script, no navigation metas, none of the router's bytes charged to its budget — a 0b page stays 0b, and its own links and forms are plain browser navigations |
a soft visit gets 204 + x-ultimate-location; the browser's own load runs it once |
Use 'document' for every GET that records something — a recipient opening a link, a download
logged as evidence, a one-time token. Use 'prefetch' only for a GET that does nothing but
render. Declaring either on a surface without client navigation refuses the boot
(X_ROUTE_NAVIGATION_INVALID).
An opted-in document (every page of the surface except a 'document' one) carries:
| Tag | Why |
|---|---|
<meta name="ultimate-navigation" content="<app>:<surface>"> |
which router may swap it in. The app's name is part of it, so two apps on one origin (web and admin) never swap each other's pages |
<meta name="x-ultimate-build" content="…"> |
the skew check |
<script src="/_x/navigation/<hash>.js" defer> |
the router: one classic script, content-addressed, immutable, 'self' under the default CSP. x dev, the container and x build --target static all serve or write it |
Budget: the router is 19,071 B minified (7,065 B gzip) As of 22.8.1. It is charged to
every route on the surface, like realtime's page boot, because it is interactivity the app opted
into. Raise a route's budget.js by the measured amount (bun run budget-raises).
Every router request carries x-ultimate-navigation: soft | prefetch, the document's
x-ultimate-surface: <app>:<surface>, its principal (x-ultimate-navigation-scope, when the
document has one) and, on a GET, its x-ultimate-build. @ultimat3/http answers before auth,
before any hook, before the handler:
| Request | Route | Answer |
|---|---|---|
| prefetch | anything but a page declaring 'prefetch' (actions, queries, assets, storage, app routes, pages) |
204, nothing ran |
| soft GET | anything but a page of THIS router's <app>:<surface> (a download, an evidence GET, a 'document' page, another surface or app) |
204 + x-ultimate-location: <same url>; the router loads it for real, and it runs once |
| soft GET | a page rendered for another principal than the router's document |
204 + x-ultimate-location, before load
|
| GET | a tab on another build |
409 from the existing build check; the router loads the page for real |
any router request answered with a 3xx (a form's 303, a sign-in wall, a load's setRedirect, an OAuth or payment hop) |
any |
204 + x-ultimate-location: <target>, cookies kept. The handler ran once; the router follows a same-origin target as its own next request, and hands another origin to the browser |
| POST | any | never gated: it is the form's submission |
A soft GET across a principal change runs auth and rate-limit twice — once before the 204 of
the principal row above, once for the real load. Expected and harmless (auth and rate-limit only;
the handler and load run once), but a tight per-actor rate limit should budget one extra request
per sign-in or sign-out navigation.
The router never lets fetch follow a redirect (redirect: 'manual'), so a CORS-refused hop to
another origin, or a target on another surface, is never fetched and then asked for again.
Clicks and submits are read on window in the bubble phase, after every handler on the page:
a handler that calls preventDefault() keeps the event.
| Taken (soft) | Left to the browser (native) |
|---|---|
left click on a same-origin <a href> / <area href>
|
a modifier key or another button; target other than _self; download; rel="external"; data-x-reload; another origin or scheme |
| a link to another page, or another query on this page | a fragment on the page already shown: the browser scrolls, nothing is fetched |
<form method="get">: navigates to its query, like the browser |
method="dialog"; a target; another origin; data-x-reload on the form or its submitter |
<form method="post">, url-encoded or multipart |
a POST with a chosen file (the browser's own upload progress); enctype="text/plain"
|
The submitter's formaction, formmethod, formenctype, formtarget and its own name/value are
honoured.
| Answer | Result |
|---|---|
| a page of this surface, build and principal | swapped in |
| a 4xx/5xx page to a GET, whatever rendered it (the framework's own error page carries no surface meta) | swapped in: a load that wrote and then threw is never run a second time. The next click is a real load |
204 + x-ultimate-location, same origin |
followed as the router's next request (at most 5 hops), or loaded when it names the URL just asked for |
204 + x-ultimate-location, another origin |
the browser goes there |
an empty 204
|
nothing: the browser stays, as it does for a 204 navigation |
| not a page (a PDF, a zip, JSON) | handed to the browser from the bytes already received: saved under its Content-Disposition name when attachment, shown otherwise. Never asked for again |
| a POST answered in place with a page | swapped in: it cannot be repeated. When it was rendered for ANOTHER principal or build (a sign-in that renders instead of redirecting), every tab's cache is emptied and this tab stops swapping: every later link and form is a real document load |
| a page from another surface, build or principal where no framework server answered (a static host) | a full load |
| Failure | Result |
|---|---|
| a GET fails on the network | the browser loads it (its own offline page) |
| a POST fails on the network, or its answer cannot be read |
never re-sent. ultimate:navigation-error on document (cancelable, detail: { url, method, reason }); its default is a GET load of the page the visitor is on, which shows what the server actually has |
| the swap throws part-way, or the answer's body cannot be read | a real load of the page (GET), or ultimate:navigation-error (POST) — never a half-replaced page |
A failed swap or body read loads a GET for real, so its route runs a second time. That is why a
page whose GET records anything must be navigation: 'document': the server then never runs it for
a soft request at all.
A failed POST's default is a GET reload of the current page. On a page where reloading has an
effect of its own (it is itself a 'document' page, or its GET records something), put
data-x-reload on its forms — the browser then submits them natively — or cancel
ultimate:navigation-error and show the failure in place.
A navigation started while another is in flight aborts the older one; the slower answer is never swapped in over the newer page.
In one order, so no frame is unstyled and no island runs twice:
- Stylesheets the next page links and this one has not loaded are loaded first.
-
Islands of the old body are disposed: each one's
mountreturn value, when it is a function, is called (Solid'srenderreturns one). -
Body replaced. Elements marked
data-x-persist="<id>"in both documents are carried across live (same node, same state, islands inside still mounted). What the element SAYS about the page follows the incoming one: its own attributes (the ones a server document set — never one a script added), and every descendant'saria-currentwith theclassbeside it, matched byidor by position. A sidebar's active item moves. -
Head brought level by markup: server-rendered tags only this page had go (JSON-LD
included), tags only the next has arrive, identical ones stay. Tags a script added at run
time (a CSS-in-JS
<style>, analytics) are the tab's and are never removed. The previous page's own stylesheets it no longer links are retired after the swap.<html lang>anddirfollow. -
Scripts: the next page's head
<script src>and its body scripts run, in order. The inline hydration runtime runs again and boots the new islands; it visits each island root once per tab, so a carried island is not booted twice. Asrcthis tab already ran is not run again. An inline head script runs when its TEXT is new to this head (once per page that carries it, as a full load would), never when the current head already has it (the theme boot, on every page); one only the previous page had is removed. Every inline script, head or body, still needs its CSP hash — the router re-inserts the same text, and the policy judges it as on a full load.
Inside document.startViewTransition when the browser has it and prefers-reduced-motion is not
reduce. Style the transition with the standard ::view-transition-* pseudo-elements. While it
animates the browser hit-tests every press to <html>, so a press skips the animation to the new
page and its click is given to the element under the pointer (As of 22.8.1): the visitor's first
click after a swap is never lost.
| Concern | Behaviour |
|---|---|
| history |
pushState per navigation, each entry naming the document it shows; replaceState for the same URL. Back/forward to an entry whose document is not the one on screen swaps it in; an entry an app pushed shows the document of its path |
| scroll | top on a new page; the fragment's element for #id; back AND forward restore the saved position (saved as the visitor scrolls). Always instant, whatever scroll-behavior says |
| focus | moved to the new page's <main> (else its first <h1>), tabindex="-1" added if needed |
| announcement | the new document.title, in a polite aria-live region the router owns |
| progress |
data-x-navigating on <html> once a navigation runs past 150 ms. No bundled UI: style it, e.g. html[data-x-navigating] main { opacity: 0.6 } inside prefers-reduced-motion: no-preference
|
On intent: pointerover that rests 65 ms, focusin, touchstart. The server answers only pages
that declared navigation: 'prefetch'; everything else is an empty 204, remembered so a second
hover does not ask again. Answers wait in a per-tab memory cache (never storage), 30 s, at most 20
documents, keyed with the query sorted and without the fragment.
| A prefetched answer is never used when | |
|---|---|
| it is not a 2xx page, or it was a hand-over (a redirect, a login wall) | the click asks again |
the server said no-store and the click came more than 5 s later |
the click asks again |
| The cache is emptied by | |
|---|---|
| any POST the router sends | |
any write through the framework's client (rpc, a mutation, an upload — @ultimat3/core's onClientWrite) |
|
a principal change: onRescope, a load for another principal, a POST answered for one, and every hand-over of the very URL asked for (the server's principal check answers that way, and the router cannot tell it from a 'document' page — so it forgets either way) |
|
a BroadcastChannel('ultimate:navigation') message from another tab — each of the above posts one |
Never prefetched at all: a link with data-x-no-prefetch, anything the click rules leave to the
browser, anything on Save-Data or a 2g/slow-2g connection, and the page a navigation is
already fetching. A press or click cancels a pending hover's prefetch, and the focus a press gives a
link is not an intent — so a fast click sends exactly one request (As of 22.8.1).
As of 2026-09. A document that carries no router — every page of a surface outside
navigation.client, and every navigation: 'document' page — carries one
<script type="speculationrules"> instead: the browser fetches a link's document before the click,
and the full-page load that follows paints from memory. No JavaScript ships for it; a 0kb page
stays 0kb (budgets.ts reads the block as data). Prefetch only, never prerender — a prerender
runs the next page's scripts for a page nobody opened.
navigation: { speculation: { prefetch: 'moderate', exclude: ['/blog/borrador-*'] } },speculation.prefetch |
The browser fetches |
|---|---|
'moderate' (default) |
on pointer rest (~200 ms) or pointer down |
'conservative' |
on pointer down only |
false |
nothing: no tag, no CSP source |
The rules are an allow-list from the route table — a prefetch is a real GET with the visitor's cookies, so only a page the table says is a pure read is a candidate:
| A page is a candidate when | Why |
|---|---|
its surface has no router, and it is static or isr with no policy |
it was rendered at build time, for nobody |
its surface has the router, and it declared navigation: 'prefetch'
|
the app's own statement that its GET is a pure read |
Never a candidate: a navigation: 'document' page, an ssr page of a surface without the router
(it has no way to say its GET records nothing), an offline: 'network-only' page, an api/ route,
and anything that is not a page — /_storage/*, /mcp*, an app's plain routes. Each candidate is
one URL pattern covering every routed locale ({/en}?/precios, {/en}?/blog/:slug);
speculation.exclude subtracts more, as URL patterns starting with /.
The body is the same string on every document of the app, sorted, and script-src admits it by
sha256 hashed from that string (page-speculation.ts), so the enforced policy needs no
'unsafe-inline'. Browsers without Speculation Rules ignore the block.
| Want | Write |
|---|---|
| a full load for this link or form | data-x-reload |
| soft on click, never fetched early | data-x-no-prefetch |
| a full load for every link to a page, from anywhere |
navigation: 'document' on its route |
| an element that survives navigations |
data-x-persist="<stable id>" on it, in both pages |
Dispatched on document:
| Event | When | detail |
|---|---|---|
ultimate:navigate |
before the fetch. Cancelable: preventDefault() keeps the tab where it is |
{ url, method } |
ultimate:navigated |
after the swap, the scroll and the new scripts | { url } |
ultimate:navigation-error |
a POST that failed or could not be shown, a swap that failed on a POST. Cancelable: the default is a GET of the current page | { url, method, reason } |
The names, attributes and headers are exported from @ultimat3/render (NAVIGATE_EVENT,
NAVIGATED_EVENT, NAVIGATION_ERROR_EVENT, NAVIGATION_RELOAD_ATTRIBUTE,
NAVIGATION_NO_PREFETCH_ATTRIBUTE, NAVIGATION_PERSIST_ATTRIBUTE, NAVIGATING_ATTRIBUTE,
NAVIGATION_HEADER, NAVIGATION_LOCATION_HEADER), with the pure rules the router runs
(linkVerdict, formVerdict, responseVerdict, reusable, mayPrefetch). The server half is
@ultimat3/http's navigationGate, redirectForRouter and relocate.
| What | Where |
|---|---|
| every client rule, as pure functions |
packages/render/src/navigation-rules.test.ts, navigation-cache.test.ts, navigation-dom.test.ts, route-navigation.test.ts
|
| the server gate and the redirect hand-over, counting handler runs | packages/http/src/navigation.test.ts |
| speculation rules: which pages are candidates, the tag on a router-less document only, the CSP hash of the served bytes |
packages/cli/src/page-speculation.test.ts, packages/render/src/speculation-rules.test.ts
|
real pages: prefetch and 'document' run no load, another principal or app is refused, a misplaced key refuses the boot |
packages/cli/src/page-navigation.test.ts |
| writes announced after they settle | packages/core/src/client-writes.test.ts |
| in a real Chrome, through the real pipeline under an enforced CSP: every rule above, counted in route executions | packages/cli/e2e/client-navigation-*.e2e.test.ts |
| the reference app, opted in: real pages, sign-out, a like after a round trip, scripting off | examples/dummy/apps/web/e2e/client-navigation.e2e.test.ts |
Ultimate — v23.0.0 As of 2026-09. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Client data
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- Client navigation
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
- Static assets
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
- CI: the gate across parallel jobs
Reference