Router Protocol
The contract between defineRouter and the router client.
Scope
unstable_defineRouter and waku/router/client implement the two ends of this protocol. A custom route resolver does not need to implement it: supply element sources to defineRouter, as shown in Custom Router.
Read this guide when changing Router itself or implementing another client binding with waku/router/client-core. A router with a different protocol can use the Minimal API directly.
Minimal owns Flight transport, element storage, and etags. It does not interpret Router's IDs or decide which URL to display. Router uses public Minimal APIs. Its control entries are reserved by Router; Minimal treats them as application data.
Client Navigation Contract
The built-in Router uses the following contract. Custom bindings own their navigation policy; the controller's apply-once boundary applies to every binding.
| Case | Supported behavior |
|---|---|
| Ordinary client navigation | Follow ready intents before committing their source route. Initial client rendering observes intents after commit; instant navigation explicitly permits a preview. |
| A pending intent | Apply it only while its response or captured navigation scope is current. A newer navigation can make it stale. |
| A server function response with ROUTE | After origin checks, pass all response elements to applyRouteResponse once, including same-route updates. The binding owns its elements and intent; Minimal does not merge them again. |
| A server function response without ROUTE | Observe its intent in the request's navigation scope and let Minimal merge its partial elements. The return value is separate from those elements. |
| An absolute redirect | Hand control to the browser. Recovery after a cancelled or blocked departure is outside Router's contract; use a native anchor for flows needing native unload cancellation. |
Completing a navigation or response callback does not mean React has painted. The number and scheduling of element merges are implementation details. See Document Navigation Handoff for the application alternative when unloading can be cancelled.
Route responses are authoritative for the represented route. A full route response may reconcile query or hash changes made directly with the History API; a partial response does not navigate. Use Router's push, replace, or search hooks when URL changes must stay coordinated with route rendering. Custom bindings own any additional URL synchronization; see Server Function Intent for the Navigation API example's fragment behavior.
If a server function response arrives without a connected binding, its return value remains available and its elements and intents are discarded. A callback that has already started belongs to the binding's navigation lifecycle.
Requests and Slots
These are different identities:
- A URL has a pathname, query, and client-owned fragment.
- An RSC input identifies a route or an independently requested slice.
- A slot ID identifies content inside an element map.
Use unstable_encodeRoutePath(pathname) and unstable_createRscParams(query) from waku/router/client-core for route requests. Fragments are not sent to the server. Minimal handles the HTTP endpoint and its wire encoding.
| Slot ID | Meaning |
|---|---|
| root | Document composition; its ID is shared across routes. |
| route:<pathname> | Route composition scoped to one pathname. |
| Other resolver-owned IDs | Content composed with Slot; IDs are shared wherever they appear. |
| slice:<id> | A slice, bundled in a route or fetched independently. |
The resolver calls its source route; defineRouter maps it to the path-scoped slot. An immutable slot must have the same content for its lifetime. Path-scoping lets immutable route compositions coexist without claiming that different URLs have the same content. Merges can retain previously visited routes' slots.
Response Metadata
Route responses also carry these entries. They are control data, not components to render with Slot.
| Entry | Contract |
|---|---|
| ROUTE | [pathname, query] of the route the server rendered, not necessarily the requested route. |
| IS_STATIC | The whole route's sources are immutable, so its content can be reused without a request. It does not mean HTML was emitted at build time. |
| INTENT | Weak promise for a JSON string with { status: 404 } or { status, location }, or null to clear the intent. An unused promise may remain pending or resolve to undefined. |
| HAS404 | A custom /404 route is available. Present when true. |
| IS_ORIGIN | A server function requested a refresh of its request-time origin. The server function routing enhancer consumes this marker before merging. |
Use unstable_getRouteFromElements, unstable_isStaticFromElements, and unstable_has404FromElements from waku/router/client-core to read route metadata. Independently fetched slices and server function calls that do not rerender a route need not contain it. Neither a server function's return value nor Minimal's etags are Router metadata.
These contracts describe server responses. The standard client also writes ROUTE and IS_STATIC for cached and instant paints, so the live element map is not necessarily a settled server response. The binding owns navigation intent, errors, and history, and reconciles them with elements at commit boundaries.
An immutable route composition is a reusable shell, not proof that every slot it renders is immutable. A shell can contain a mutable page. The client cache therefore distinguishes hasCachedShell from canReuseStaticRoute.
Responses skipped by a ready redirect do not populate the retained-route cache. Revisiting a skipped route can require another request. During ordinary navigation, the current page stays visible until the redirect destination is ready to commit.
Partial Responses
Minimal can send the etags of a supplied unstable_base with a fetch. The server omits slots whose validators match; fetching returns only response entries. A response is consequently not guaranteed to be a complete renderable tree. Callers retain omitted slots or combine a standalone snapshot explicitly.
Route metadata describes the resolved route even when its slots are omitted. For example, a dynamic page whose etag matches is omitted, but IS_STATIC remains false. Looking only at the returned slots would give the wrong reuse decision.
Use Minimal's unstable_combineElements when combining element maps. Object spreads discard the validators associated with their values.
Requested, Rendered, and Browser URLs
| Situation | Requested route | Rendered route | Browser URL after commit |
|---|---|---|---|
| Ordinary navigation | /post?q=one | /post?q=one | /post?q=one |
| Resolver rewrite | /old | /new | /new |
| Custom not-found route | /missing?q=one | /404?q=one | /missing?q=one |
requestRedirect and requestNotFound record navigation in INTENT without throwing. The client observes it independently from slot rendering, including when the requesting component stops with cancelRender(). Do not await an unused intent to decide whether a route has loaded. HTTP document requests use redirect statuses or render the 404 page when the shell has not been sent. Router does not start a form action if an intent has already been recorded. An action that has already run retains its form state when the handler renders the not-found view. A source factory that cancels stops element construction; Router handles its recorded intent before starting an action or producing a response. Other source-factory errors propagate.
Route intents are runtime decisions from dynamic rendering, server functions, or handler interceptors before next(). Immutable, cached, and prerendered sources produce content only. Their factories and nested server components must not call requestRedirect() or requestNotFound(). Prerendered route payloads clear INTENT with null, without recording navigation. Standalone static slice payloads omit INTENT to preserve the active route's intent. Static-only deployments use host redirects and omit invalid build paths or render fallback content.
The first recorded intent wins. Its promise can settle and reach the browser before other components finish, so a later intent cannot replace it. Concurrent components have no guaranteed ordering; redirect and not-found intents have no priority over each other.
Weak-promise listeners notify the transport synchronously during rendering. The stable React transport publishes resolved values at Flight record boundaries and stops observing them when its read loop observes EOF or a source error. React's experimental native weak references also need synchronous notification to emit an intent before the last render task completes and closes the stream.
During client navigation, the loader subscribes and yields one microtask to read an available intent, allowing React to initialize lazy Flight values and deliver its callbacks. It follows ready intents before committing the response, preserving the active content while the destination loads. An intent that arrives after commit follows from the displayed route. The destination uses an existing route cache when possible; otherwise it needs its own request so that rendering uses the destination request's URL and context.
Initial client rendering observes intents after commit. See Status and Streaming Notes for redirects before source content mounts.
An app-relative redirect must change the path or query. Fragment-only changes belong to client navigation. After committing a response, the Router handles its settled intent once. Each server request creates its own intent promise.
Recording an intent does not terminate rendering. Return after requesting navigation and let ordinary rendering in the response finish. Discarded response streams are not guaranteed to be aborted, including shared prefetches and adopted responses. See Redirects and Not Found.
The built-in Router observes intents from independent slice fetches and intent-only server function responses in the navigation state captured when the request starts. A later Router navigation, including a hash-only change, makes these pending intents stale. A navigation already in flight also takes precedence. A full route response keeps its own intent; a hash-only navigation that reuses that route preserves it. Minimal's useFetchRsc_UNSTABLE only returns response data; custom integrations own the routing decisions for requests they make directly.
Each Router owns its Minimal Root and that Root's intent observer. Separate Router instances keep their observers separate, including during unmount cleanup.
The client uses HAS404 to decide whether a not-found intent follows /404 or shows its generic fallback. Follow decisions share loop detection and a navigation budget through unstable_decideRouteIntent. Absolute destinations leave the document; app-relative destinations use client navigation. See Redirects and Not Found for SSR and streaming.
Fragments remain client-owned. Static route reuse also preserves the browser's query instead of treating build-time route metadata as a query-changing redirect. Reading ROUTE is therefore not enough to decide what to write to history.
For an HTTP redirect to a different static route, Router's private RSC enhancer reads the destination query from the final response URL and corrects the decoded route metadata for navigation and prefetch. It wraps the configured app enhancers, so it observes the response before their fetch wrappers transform it. Custom client bindings can implement the same reconciliation with Minimal's RSC enhancer API.
A decoded-result cache in the configured app enhancers stores results before Router reconciles them. Cache hits that bypass the network cannot recover redirect queries from the response URL. When an enhancer fetches multiple responses, Router uses the last response it sees; it does not identify which response supplied the returned elements.
Once a static route is cached, navigation can reuse it without a request. Redirect rules that must run for each query change require a dynamic route.
A route with only immutable sources is eligible for whole-route reuse without requiring those sources to be in the server element cache. A runtime response with an already-recorded intent disables that reuse; its immutable content can still use etags and the element cache. Handler interceptor presence does not change the route's static classification.
The element cache stores serialized outcomes without inspecting nested React errors. A successfully serialized immutable subtree is reusable even if it contains a cancellation. Use a dynamic source for results that should retry or vary by request. A source factory or serialization operation that throws creates no cached entry. The cache stores content bytes, without capturing or replaying routing intents.
Prerendered files and cached static routes can be served or reused without running runtime handler interceptors. Routes requiring authorization or another check on every visit must be dynamic; registering an interceptor does not make static content private. This concerns server handler interceptors, not client unstable_routeInterceptor or the browser Navigation API.
Handler interceptors record routing intents before calling next(). Use requestRedirect() or requestNotFound(), then return next() to let Router produce the response. This skips the requested API handler or server function. A skipped server function's response carries the intent without a return value; the function's caller receives undefined. The handler renders any fallback within that call, so the interceptor's async context remains active. Throwing cancellation outside next() escapes to Minimal's no-op handling.
Once next() returns a response, the interceptor owns modifications, replacement, and cleanup of any body it discards. Recording an intent afterward does not rewrite the HTTP response. On an open Flight stream it may still reach the client, but this is not guaranteed after next() returns.
API handlers own their returned HTTP responses. Router does not apply intents recorded inside them. Use native responses for API redirects and not-found results.
When a render-time intent replaces a document, Router starts cancellation of its body without waiting for stream cleanup, which may depend on another tee or clone consumer. The replacement supplies the entire response. Add cookies and other application headers in an interceptor after next() returns the final response. Redirects and not-found documents default to Cache-Control: private, no-store.
On failure, retained elements can describe an earlier route. The attempted URL and error state belong to the client binding, not the server metadata.
Navigation Hrefs and Search
Link, push, replace, prefetch, and requestRedirect consume URL strings. unstable_createRouteHref from waku/router binds a local pattern and optional search codec to a function returning an app-base-free primitive string. Its type-only brand records the normalized route pattern, not membership in a finite static build. Construction requires no codec registry, provider, or additional Flight metadata. See Typed Routes.
Search interpretation belongs to the code supplying the codec: createPages parses incoming page queries, and client search hooks parse the current query using an explicit factory or { path, searchCodec } descriptor. A custom defineRouter resolver receives the query string and chooses how to parse it. Generated SearchCodecsConfig types describe PageProps.search, not navigation. Outbound construction calls serialize without reparsing its result; the destination must independently validate its incoming query.
Loading Without a Cache
unstable_load from waku/router/client-core accepts a fetch callback. It recovers from real HTTP 404s, including missing files on a static host, within the follow budget and stops waiting when cancelled. It does not require the Router cache or merge elements. It follows ready intents before returning a payload; the binding observes intents that arrive after commit.
This hook binds the loader to the enclosing Minimal Root without a Router cache:
import { useFetchRsc_UNSTABLE } from 'waku/minimal/client';
import {
type Unstable_FetchRoute,
type Unstable_LoadOptions,
type Unstable_RouteProps,
unstable_createRscParams,
unstable_encodeRoutePath,
unstable_load,
} from 'waku/router/client-core';
function useLoadRoute() {
const fetchRsc = useFetchRsc_UNSTABLE();
const fetchRoute: Unstable_FetchRoute = ({ route }, signal) =>
fetchRsc(
unstable_encodeRoutePath(route.path),
unstable_createRscParams(route.query),
{ signal },
);
return (requested: Unstable_RouteProps, options: Unstable_LoadOptions) =>
unstable_load(fetchRoute, requested, options);
}Call useLoadRoute() while rendering your binding, then call its returned function from a navigation handler:
const outcome = await loadRoute(requested, {
signal: controller.signal,
has404,
url: requestedUrl,
follows,
});Here follows is zero for a new navigation or the preceding follow count. The callback receives each attempt's route, intended browser URL, and follow count, plus the cancellation signal. It can supply unstable_base to fetchRsc for etag reuse when the binding retains the corresponding elements.
| Outcome | Binding responsibility |
|---|---|
| loaded | Reconcile response metadata and queue its elements with navigation intent. |
| reused | Retain existing content and queue the requested route metadata and intent. |
| failed | Present the error at the attempted URL. |
| aborted | Discard the result. |
| leave | Commit the attempt's url with the navigation's history mode, then replace the document with location. |
A callback returns undefined only to reuse usable content already in this Root. An unmatched route uses the custom page when available. With no custom page, or on a static host, the request can instead reject with an HTTP 404. outcome.route describes the final attempt, not necessarily the rendered destination: reconcile ROUTE using the URL rules above. Observing INTENT continues the same follow budget.
Bindings that want Router's cache policy call useRouterCache_UNSTABLE() during rendering. Its cache.load(requested, { ...options, base: elements }) wraps the same loader with static reuse, prefetch, and optional response adoption. A cache-free binding does not need to construct a cache-shaped adapter. Network results contain response patches; omitted entries are not filled from the request-time base. A response from a different build, including an adopted prefetch, fails the load with an error that unstable_isBuildIdMismatch recognizes; reload at outcome.url to recover. Cached prefetches return their stored element maps. Bindings that read a complete snapshot can materialize a loaded result:
import { unstable_combineElements as combineElements } from 'waku/minimal/client';
const outcome = await cache.load(requested, { ...options, base });
if (outcome.type === 'loaded') {
const snapshot = combineElements(base, outcome.elements);
}Queue the response patch, not this request-time snapshot, when preserving later server function or client updates. unstable_buildMergePatch implements a snapshot-conflict policy for custom bindings; the standard binding queues response patches directly.
If an instant paint has already merged an adopted response, the binding must not merge it again. A follow fetch produces a new response requiring an ordinary commit.
Client Commits
The standard client keeps elements and navigation intent in one Minimal Root update queue. The intent is stored under the client-only ROUTER_STATE_ID symbol; it is neither a server response field nor another slot. Minimal merges it without interpreting it.
| State | Owner | Purpose |
|---|---|---|
| Elements and attached Router state | Minimal Root's React state | Select the content and URL intent that render together. |
| Requested or accepted route | Router ref | Cancel pending work and route server function results until UI catches up. |
| Applied snapshot identity and destination | Router's layout effect | Write history and scroll once for each committed intent. |
| Fetch error | Router's React state | Render the failure and clear it when another request starts. |
Each request carries forward accepted RSC elements as its cache baseline, which can be ahead of the last painted route. Accepted instant responses retain their prefetch fallback and reuse the eager paint's navigation intent without repeating history and scrolling. The eager merge represents its queued input and prefetch fallback as pinned values or response-backed placeholders. A private instant-state wrapper records represented slot IDs for each element snapshot, while retaining the same navigation intent identity through React replays. Acceptance backfills only unrepresented response entries and replaces the wrapper with ordinary Router state. A later queued removal of a represented slot stays removed even if the response returns it. Until that state commits, their cache baseline makes no etag claims because it can differ from the latest queued values. Static-route eligibility is learned when the accepted instant state commits. A metadata-only server function dispatch makes no element-validator claims for its destination. This baseline does not track every queued server function or client merge; response patches merge with the latest queued elements through Minimal's update queue. After a failure, refetch decisions use the retained request origin, not an instant preview whose response may have been cancelled. Server function call origins use the retained elements' route metadata as described below. Failed commits do not learn static routes from retained preview metadata. Before replacing the queued Router state, a failure resolves its retained origin into route metadata, preserving static-page queries and instant-preview origins.
An ordinary navigation queues its pending elements promise when fetching starts. Later server function and client merges follow it in React's update order. fetchRsc returns the decoded response patch without filling omitted entries from its request-time base. Returned entries replace earlier values, including equal primitive values; omitted entries retain queued values and removals. Prefetching combines its retained base with the response to provide reusable element maps; instant merging fills omitted slots from its prefetch base. A rejected instant response preserves the previous queued values rather than replacing them with prefetch values. Instant fetch cancellation stops waiting even when transport ignores the signal. The destination's route slot and protocol metadata are reapplied when the response arrives so a server function call from the previous route cannot restore its composition over the destination. Redirect and 404 follows after an instant paint queue their pending response patch before later server function and client updates, using the ordinary navigation merge and destination finalization. Response metadata identifies the resolved route and is applied independently of slot conflicts. Reusing a cached static route restores its static classification. At merge time, a missing destination route slot can be filled from the retained cache, including an accepted instant response's prefetch fallback. An existing queued value for that slot and unrelated updates or removals are preserved.
Fetching and committing are different boundaries. An ordinary fetch can finish while a client component is suspended; its navigation promise does not wait for that component. Successful ordinary navigations apply history and scrolling after the snapshot commits. Eligible instant navigations can commit a cached shell while fetching continues. Fetch failures write history without waiting for a successful content commit and render the error rather than an unfetched slot. A boundary handles initial HTTP 404s; application error boundaries handle ordinary render errors, and Minimal's Slot boundary handles render cancellation.
An empty RSC response applies no updates. Ordinary navigation preserves its current view and URL. Cancellation after an instant shell commits keeps that shell and destination URL; waiting slots settle to their retained values.
Router's useMergeInstantElements owns the eager rendering policy. It uses Minimal's merge callback to retain pinned slots, wait on the response for other slots, and include cached immutable slots and client navigation intent. The callback starts from the latest queued elements, so it cannot overwrite an earlier queued update with a rendered snapshot. Settlement adds new slots and updates response-owned overlay keys only while that instant merge still owns the queued promise. It settles in a transition so adding entries does not hide the painted shell behind the Root's Suspense fallback. Minimal owns updater replay safety, not SWR policy.
Cached and same-RSC-route commits queue before the first await, retaining the caller's React priority. Transition callbacks run immediately, so wrapping an uncached navigation does not postpone superseding the previous request.
Superseding a pending request resolves its queued elements update to an empty patch. Once the response is accepted, its elements remain in the update chain, even if rendering is suspended. A later navigation appends a new pending promise; React controls which intermediate renders commit. No committed snapshot is copied back into the queue, and unrelated updates retain their original order.
Ordinary element merges retain the attached state object by reference. The layout effect recognizes that identity and does not repeat its scroll or history intent. An external history.replaceState query/hash change is consequently not undone by an unrelated element merge. A server redirect can change the destination for the same intent; that correction replaces history rather than pushing again.
RSC hot reload selects an accepted destination before retiring the request. If only a pending fetch exists, it refreshes the visible route. This keeps an accepted but suspended navigation from being replaced by the previous page.
Server Function Intent
requestRefresh() schedules the calling route to render after the server function succeeds, without interrupting execution or changing its return value. Repeated calls coalesce. If the function records a redirect or not-found intent, Router skips the refresh and returns the function's value with its intent. A render failure after the function finishes follows normal request error or cancellation handling.
The server-function controller passes each accepted route-bearing response to the binding's applyRouteResponse callback and waits for it. This includes same-route rerenders. The callback receives all response elements, including INTENT, and owns applying them. The controller returns empty elements to Minimal so the payload is not applied again.
The built-in Router applies these responses through its navigation loader. Ready intents are followed before that route is committed; the current page stays visible while the destination loads. Pending intents remain attached to the committed route and use its normal observer. The function call stays pending while the loader follows a ready intent and retains its return value if navigation fails. A ready not-found intent without a custom page fails navigation like an HTTP 404, without committing the response's route content.
A controller from unstable_createServerFunctionRouting sends the settled route at server function request time in X-Waku-Action-Location. This header is client-to-server information. After a failed navigation, the call's origin follows the retained elements' route metadata, which can be ahead of the request's cache baseline after an instant paint. The failed destination's URL remains in history.
- requestRefresh() and the deprecated unstable_rerenderRoute() refresh that origin and mark the response IS_ORIGIN. If the user has navigated away or any navigation is pending, including a reload or hash change on the same route, the client discards its elements but retains the server function's return value.
- The deprecated unstable_rerenderRoute(destination) still selects a route to render and is not limited to the origin. Only the last requested target is rendered. An intent-only server function response uses the captured navigation scope described above; it does not override a later navigation.
Create one controller and a stable enhancer array for each Root before rendering React. Install its enhancer last in Root's rscEnhancers, and call useServerFunctionRouting_UNSTABLE(controller, callbacks) in the client binding beneath that Root. The hook connects the Root cache and current navigation callbacks in a layout effect; it does not install the enhancer. Callbacks can change without replacing the pipeline. Start mount-time server function calls in useEffect: calls before connection fail rather than inventing an origin.
const serverFunctionRouting = unstable_createServerFunctionRouting();
const rscEnhancers = [...inputEnhancers, serverFunctionRouting.enhancer];
const app = (
<Root rscEnhancers={rscEnhancers}>
<Binding serverFunctionRouting={serverFunctionRouting} />
</Root>
);The binding calls:
useServerFunctionRouting_UNSTABLE(serverFunctionRouting, {
getSettledRoute,
getPendingRoute,
applyRouteResponse,
});applyRouteResponse(elements) replaces the former onRouteChange(route) callback. A custom binding must apply all response elements, not just update its URL. It can pass adopt: Promise.resolve(elements) to cache.load and use its ordinary navigation outcome handler. To match the built-in Router, adopted responses use the same navigation and cancellation path as ordinary navigation: they supersede older work and yield to newer navigation. Resolve ready intents before committing the source response and observe pending intents after commit.
The Navigation API example rebuilds adopted URLs from path and query. A full route response can clear an initial fragment, even on a same-route rerender; custom 404 responses keep the attempted URL. If the fragment must survive, restore it with client navigation after the server function settles or implement fragment preservation in the custom binding.
The controller preserves the server function's return value after the callback resolves. If the callback rejects, the call rejects. The built-in binding shows navigation failures through its error boundary while preserving the return value; custom bindings own their error policy.
Reading the Implementation
Paths below are relative to packages/waku/src/router.
| Responsibility | File |
|---|---|
| Protocol IDs, RSC inputs, slot IDs, and metadata readers | isomorphic-utils/router-protocol.ts |
| Route resolution to elements and route metadata | define-router-utils/route-entries.ts |
| Request handling, redirects, not-found, and server function responses | define-router-utils/request-handler.tsx |
| Client server function call intent and stale-origin protection | client-core-utils/server-function-routing.ts |
| Fetch-time follows and render-time follow policy | client-core-utils/load.ts and client-core-utils/route-follow.ts |
| Element/route/history commit in the standard binding | client-utils/navigation.ts and client-utils/router-state.ts |
| Cached eager paints and response adoption | client-utils/instant-navigation.ts |
| Pinned slots, waiting values, and instant response settlement | client-utils/instant-elements.ts |
| Rendered error handling and boundary resets | client-utils/render-error.tsx and client.tsx |
packages/waku/tests/router-protocol.test.tsx exercises server responses through Minimal's client and Router's public helpers, substituting only Flight serialization. The executable alternative binding in e2e/fixtures/nav-api-spike/src/nav-binding.tsx uses public Minimal and client-core APIs; its browser tests exercise the real transport.

