Custom Router
Resolve routes directly while using Waku's router client.
When to Use a Custom Router
Use unstable_defineRouter when you want to bring your own route matching: plain strings, regular expressions, a CMS, or another route manifest. It pairs with waku/router/client; Waku handles navigation, server actions, redirects, and RSC request encoding.
For programmatic page and layout declarations, use createPages. For a different client protocol or lower-level request handling, use the Minimal API.
Server Entry
resolve receives a normalized pathname and the query string without ?. Return a route, an HTTP handler, or null for not found. All resolver callbacks return promises.
// src/waku.server.tsx
import adapter from 'waku/adapters/default';
import { Children_UNSTABLE as Children } from 'waku/minimal/client';
import { unstable_defineRouter as defineRouter } from 'waku/router/server';
import Home from './components/home';
import Post from './components/post';
const root = {
immutable: true,
render: () => (
<html>
<body>
<Children />
</body>
</html>
),
};
export default adapter(
defineRouter({
async resolve(pathname, query) {
if (pathname === '/api/health') {
return async () => Response.json({ ok: true });
}
if (pathname === '/') {
return {
elements: {
root,
route: { immutable: true, render: () => <Home /> },
},
};
}
const match = /^\/posts\/([^/]+)$/.exec(pathname);
if (match) {
return {
elements: {
root,
route: { render: () => <Post id={match[1]!} query={query} /> },
},
};
}
return null;
},
async getBuildPaths() {
return ['/'];
},
}),
);The root source renders the document and places the active route with Children_UNSTABLE. The route source renders the current route. Add other element sources under IDs you own and compose them with Slot_UNSTABLE when independently reusable content is useful.
Each source uses Minimal's Unstable_ElementSource:
- render produces its content.
- immutable: true lets the client reuse content for that slot ID indefinitely.
- getEtag optionally computes a validator for mutable content before rendering it.
An immutable slot must have the same content for its lifetime. The root ID is shared across routes, while route is mapped internally to a path-specific slot. Additional IDs are shared wherever they appear, so use distinct IDs for different content.
The server caches immutable named elements. It reuses immutable route content from build output, but does not cache new route content by requested pathname at runtime. Give a route source a cacheKey to share its cached content across pathnames, such as a reusable route shell. Sources with the same key must render the same content; use a bounded set of keys rather than arbitrary requested URLs.
Additional IDs must not start with _, a capital letter, or route:; these names are reserved for Minimal and Router metadata.
Set noSsr: true on a route to return Waku's client-rendered document fallback; its RSC response remains available. Resolving /404 supplies a custom not-found route. Its availability is checked once per router instance; the content is resolved on each visit. Supply getHas404: async () => boolean when your manifest can answer this without resolving a route, or when a catch-all also matches /404. See Redirects and Not Found for streaming behavior.
For typed search, parse query with an application-owned codec inside resolve. Share that independent codec with a local unstable_createRouteHref factory and the client search hooks; defineRouter requires no codec map or browser registration. Navigation and redirects consume the resulting URL strings. See Typed Routes for examples and migration steps.
Client Entry
Use the normal router client, not a custom Minimal client:
// src/waku.client.tsx
import { createRoot, hydrateRoot } from 'react-dom/client';
import { unstable_defaultRootOptions as defaultRootOptions } from 'waku/client';
import { ErrorBoundary, Router } from 'waku/router/client';
const app = (
<ErrorBoundary>
<Router />
</ErrorBoundary>
);
if ((globalThis as any).__WAKU_HYDRATE__) {
hydrateRoot(document, app, defaultRootOptions);
} else {
createRoot(document, defaultRootOptions).render(app);
}HTTP Handlers
Return an async function from resolve for an API endpoint. The function receives the real request, including its method, headers, and body, with the pathname relative to Waku's base path. It runs only for HTTP requests, not when the client tries to fetch a route payload.
Route params are caller-owned: match and capture them in resolve, then close over them in the handler or element source. Use getRequest from waku/router/server to inspect the request during resolution or rendering.
The request body can be read only once. If resolution needs to inspect it before returning an HTTP handler, read getRequest().clone() so the handler can still read the original body.
Build Paths
The optional getBuildPaths returns pathname strings, without queries or fragments. These use the same resolve callback as runtime requests.
- Routes with only immutable sources emit RSC and HTML files.
- Mutable routes cache only their immutable sources; mutable content is rendered at runtime.
- HTTP handlers emit their response bodies as static files.
- Immutable routes with noSsr emit RSC and a default client-rendered HTML document.
For explicit build planning, return { pathname, route?, prerender?, prefetchPattern? } targets. route supplies build-specific sources or an HTTP handler rather than calling resolve. prerender: false caches only immutable sources without emitting a page, even when every source is immutable. prefetchPattern is a regular expression matching paths that share the target's client modules.
Omit a path to skip its build output. Omit getBuildPaths entirely for runtime-only routes. Custom routers do not need PathSpec or component source-file metadata.
Include /404 in getBuildPaths to emit a static 404.html.
Independently Requested Elements
The optional resolveElement returns an element source or null for a requested slot ID. Existing lazy Slice components from waku/router/client request IDs of the form slice:<id>.
// src/waku.server.tsx
async resolveElement(id) {
if (id === 'slice:summary') {
return { immutable: true, render: () => <Summary /> };
}
return null;
},
async getBuildElementIds() {
return ['slice:summary'];
},getBuildElementIds lists immutable elements to emit as standalone RSC files, including lazy slices needed on a static deployment. It works independently of getBuildPaths. Currently only slice:<id> IDs are supported. Listed elements must resolve and be immutable; otherwise the build fails. Unlisted elements require a runtime server when fetched independently.
Return { id, source } instead of an ID string when the build should use an explicit source rather than resolveElement.
Include an element directly in a route's elements when it should arrive in the route payload. There are no separate page, layout, or slice declarations in defineRouter; those are createPages authoring concepts.
Boundaries
defineRouter owns the protocol expected by waku/router/client: reserved metadata, root and route slots, action rerenders, redirects, 404 handling, SSR, and build output. Custom routers supply resolution and element sources, without importing internal IDs or encoding helpers from waku/router/client-core.
For changes to Router itself or another client binding, see Router Protocol.
createPages uses this same public API for runtime and builds. It owns page/layout matching, configuration metadata, and source-file pruning. fsRouter uses only createPages' public authoring API to translate route modules into those declarations.
The API is unstable and may change.

