--- slug: router-protocol title: Router Protocol description: The contract between defineRouter and the router client. category: Low-level APIs order: 25 --- ## 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](/guides/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](/guides/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. ## 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:` | Route composition scoped to one pathname. | | Other resolver-owned IDs | Content composed with `Slot`; IDs are shared wherever they appear. | | `slice:` | 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` | Every source of that route is immutable, including sources omitted by matching etags. It does not mean HTML was emitted at build time. | | `HAS404` | A custom `/404` route is available. Present when true. | | `IS_ORIGIN` | An action used the no-argument `rerenderRoute()` form to refresh its request-time origin. The action 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 actions that do not rerender a route need not contain it. Neither the action 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`. ## 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` | | Eager internal redirect | `/old` | `/new` | `/new` | | Custom not-found route | `/missing?q=one` | `/404?q=one` | `/missing?q=one` | An early internal redirect can be resolved on the server into the destination's elements in the same response. `ROUTE` tells the client which destination landed. Not every redirect can be resolved this way: document redirects leave the router, and redirects thrown by streamed slots are handled by the client error boundary. Similarly, a fetch can succeed before a streamed slot throws not-found. The client uses `HAS404` to decide whether to follow `/404` or show its generic not-found fallback. Follow decisions share loop detection and a navigation budget. See [Redirects and Not Found](/guides/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. 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 `unstable_redirect` 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](/guides/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 follows fetch-time redirects and custom 404s, enforces the follow budget, and stops waiting when cancelled. It does not require the Router cache or merge elements. This hook binds the loader to the enclosing Minimal `Root` without a Router cache: ```tsx 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: ```ts const outcome = await loadRoute(requested, { signal: controller.signal, has404, settled, url: requestedUrl, follows, }); ``` Here `settled` identifies the route whose composition the binding retains for reuse, which can be accepted before it paints. `follows` is zero for a new navigation or the preceding count for a render-time follow. 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. | | `external` | Leave the document for `url`, applying the binding's history policy. | | `failed` | Present the error at the attempted URL. | | `aborted` | Discard the result. | A callback returns `undefined` only to reuse usable content already in this Root; missing routes must reject with a not-found error. A hash-only fetch redirect back to the settled RSC route also returns `reused`, avoiding an identical fetch. `outcome.route` describes the final attempt, not necessarily the server-rendered destination: reconcile `ROUTE` using the URL rules above. Streamed-slot errors must continue the same follow budget through the binding's error boundary. Each boundary dispatches a caught error's follow once; React effect replay does not start another request. Separate boundaries handle a shared error independently. 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. Cached prefetches return their stored element maps. Bindings that read a complete snapshot can materialize a loaded result: ```ts 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 action 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 action 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 action dispatch makes no element-validator claims for its destination. This baseline does not track every queued action 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. Action 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 action 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 an action 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 action 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 and external redirects write history without waiting for a successful content commit; failures render an error rather than a slot that was never fetched. The render-error boundary has its own reset conditions, separate from clearing the Router's fetch error. 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. ## Action Intent A controller from `unstable_createActionRouting` sends the settled route at action request time in `X-Waku-Action-Location`. This header is client-to-server information. After a failed navigation, the action 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. - `rerenderRoute()` refreshes that origin and marks the response `IS_ORIGIN`. If the user has navigated away or a conflicting navigation is pending, the client discards its elements but retains the action's return value. - `rerenderRoute(destination)` and action redirects express navigation intent. Their destination must not be discarded merely because the user left the origin. Create one controller and a stable enhancer array for each Root before rendering React. Install its `enhancer` last in Root's `rscEnhancers`, and call `useActionRouting_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 actions in `useEffect`: actions before connection fail rather than inventing an origin. ```tsx const actionRouting = unstable_createActionRouting(); const rscEnhancers = [...inputEnhancers, actionRouting.enhancer]; const app = ( ); ``` The binding calls: ```tsx useActionRouting_UNSTABLE(actionRouting, { getSettledRoute, getPendingRoute, onRouteChange, }); ``` See [Rerender After Actions](/guides/rerender-after-actions). ## 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 action responses | `define-router-utils/request-handler.tsx` | | Client action intent and stale-origin protection | `client-core-utils/action-routing.ts` | | Fetch-time follows and render-time follow policy | `client-core-utils/load.ts` and `client-core-utils/error-route.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.