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 function calls, 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. The cache reuses the serialized outcome, including any nested cancellation.
Immutable and cached sources produce content only. Their factories and nested components must not call requestRedirect() or requestNotFound(). Put routing decisions in dynamic sources, server functions, or runtime handler interceptors before next().
If a source factory throws, including calling unstable_cancelRender(), its result is not cached. A factory cancellation during a build fails the build. Use dynamic sources for request-dependent or retryable rendering.
A route whose sources are all immutable can be reused by the client without a request, whether or not those sources have a cacheKey. Handler interceptors do not prevent this reuse. A decision that must run on each visit requires a dynamic route.
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 router definition to return Waku's client-rendered document fallback; its RSC response remains available. To supply a custom not-found route, resolve /404 and provide getHas404: async () => true. Without getHas404, the router assumes there is no custom page; it does not probe the resolver. The callback supplies availability when needed, and the content is resolved on each visit. createPages and fsRouter provide availability from their route registry. 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.
The handler owns its returned Response, including its status and headers. Return Response.redirect() for a redirect or a response with status 404 for missing data. requestRedirect() and requestNotFound() are not supported inside API handlers; they serve dynamic React rendering and server function calls. An intent recorded during runtime route resolution, or by a handler interceptor before next(), is applied before starting the API handler.
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, routerDefinition?, prerender?, prefetchRegExp? } targets. routerDefinition supplies a build-specific router definition or an HTTP handler rather than calling resolve. prerender: false caches only immutable sources without emitting a page, even when every source is immutable. prefetchRegExp 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.
Build-time sources render content without route intents. Exclude invalid static paths from the build plan or render fallback content. On static-only deployments, configure redirects at the host; see Redirect Maps.
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.
Build Output at Runtime
A route the build prerenders, one whose sources are all immutable and whose target doesn't set prerender: false, stays available from the build output, as does each built element ID. At runtime, defineRouter serves those from the build output without calling resolve or resolveElement, so a broader route, such as a dynamic /[slug], cannot take over a static path whose module was pruned. This applies to every request method: to return an HTTP handler for a POST to a prerendered path, set prerender: false on that target or leave it out of getBuildPaths. Any immutable source a route returns also renders from the build cache. A router can therefore leave out modules that only immutable sources use:
- getPrunableFiles() runs after the build and lists those source files, so the server bundle can drop them.
- initialize() runs before the first request or the build, so it can prepare what resolve reads. After a failure, it runs again on the next request.
Combining Routers
unstable_combineRouters combines routers created by unstable_defineRouter, createPages, or fsRouter, for example to add config routes next to file routes.
// src/waku.server.tsx
import { createPages, fsRouter } from 'waku';
import adapter from 'waku/adapters/default';
import { unstable_combineRouters as combineRouters } from 'waku/router/server';
import Company from './components/company';
import Root from './pages/_root';
export default adapter(
combineRouters(
createPages(async ({ createPage, createRoot }) => [
createRoot({ render: 'static', component: Root }),
createPage({ path: '/company', render: 'static', component: Company }),
]),
fsRouter(import.meta.glob('./pages/**/*.{tsx,ts}')),
),
);A route or an independently requested element belongs to the first router that resolves it. The build fails when two routers list the same build path or element ID. A catch-all or dynamic route should be in the last router, or it would take routes meant for the routers after it.
The routers share element IDs, so an ID such as root or layout:/ must render the same content in every router that defines it. In the example, createPages uses the root component of _root.tsx. Handler interceptors from every router wrap every request, in router order. If one router's initialize fails, the next request runs every router's initialize again, so each must be safe to rerun; those of createPages and fsRouter are.
Generated types from fsRouter cover only the file routes.
Boundaries
defineRouter owns the protocol expected by waku/router/client: reserved metadata, root and route slots, server function 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 and decides which source files to prune. fsRouter uses only createPages' public authoring API to translate route modules into those declarations.
The API is unstable and may change.

