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.

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 IDMeaning
rootDocument composition; its ID is shared across routes.
route:<pathname>Route composition scoped to one pathname.
Other resolver-owned IDsContent 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.

EntryContract
ROUTE[pathname, query] of the route the server rendered, not necessarily the requested route.
IS_STATICEvery source of that route is immutable, including sources omitted by matching etags. It does not mean HTML was emitted at build time.
HAS404A custom /404 route is available. Present when true.
IS_ORIGINAn 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

SituationRequested routeRendered routeBrowser 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 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.

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.

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:

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

OutcomeBinding responsibility
loadedReconcile response metadata and queue its elements with navigation intent.
reusedRetain existing content and queue the requested route metadata and intent.
externalLeave the document for url, applying the binding's history policy.
failedPresent the error at the attempted URL.
abortedDiscard 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:

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.

StateOwnerPurpose
Elements and attached Router stateMinimal Root's React stateSelect the content and URL intent that render together.
Requested or accepted routeRouter refCancel pending work and route action results until UI catches up.
Applied snapshot identity and destinationRouter's layout effectWrite history and scroll once for each committed intent.
Fetch errorRouter's React stateRender 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.

const actionRouting = unstable_createActionRouting();
const rscEnhancers = [...inputEnhancers, actionRouting.enhancer];
const app = (
  <Root rscEnhancers={rscEnhancers}>
    <Binding actionRouting={actionRouting} />
  </Root>
);

The binding calls:

useActionRouting_UNSTABLE(actionRouting, {
  getSettledRoute,
  getPendingRoute,
  onRouteChange,
});

See Rerender After Actions.

Reading the Implementation

Paths below are relative to packages/waku/src/router.

ResponsibilityFile
Protocol IDs, RSC inputs, slot IDs, and metadata readersisomorphic-utils/router-protocol.ts
Route resolution to elements and route metadatadefine-router-utils/route-entries.ts
Request handling, redirects, not-found, and action responsesdefine-router-utils/request-handler.tsx
Client action intent and stale-origin protectionclient-core-utils/action-routing.ts
Fetch-time follows and render-time follow policyclient-core-utils/load.ts and client-core-utils/error-route.ts
Element/route/history commit in the standard bindingclient-utils/navigation.ts and client-utils/router-state.ts
Cached eager paints and response adoptionclient-utils/instant-navigation.ts
Pinned slots, waiting values, and instant response settlementclient-utils/instant-elements.ts
Rendered error handling and boundary resetsclient-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.

designed bycandycode alternative graphic design web development agency San Diego