Typed Routes
Build route URLs with locally inferred params and search codecs.
- Experimental
Overview
Waku navigation accepts URL strings. Use unstable_createRouteHref from waku/router to construct them with typed path params and optional search. Factories are optional: <Link to="/about"> and router.push('/about') work without one.
The APIs in this guide are unstable and may change. For upgrading existing typed navigation, see Migration.
Build Route Hrefs
Define a factory in a module that both Server and Client Components can import:
// ./src/lib/routes.ts
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
export const post = createRouteHref({ path: '/posts/[slug]' });
export const docs = createRouteHref({ path: '/docs/[...path]' });
export const home = createRouteHref({ path: '/' });Call it to encode params and optionally append a fragment:
import { Link } from 'waku';
import { docs, home, post } from '../lib/routes';
export default function Navigation() {
return (
<nav>
<Link to={home()}>Home</Link>
<Link to={post({ params: { slug: 'hello world' }, hash: 'top' })}>
Post
</Link>
<Link to={docs({ params: { path: ['guides', 'routing'] } })}>Docs</Link>
</nav>
);
}- params is required for patterns with slugs. Named params are strings; catch-alls accept readonly arrays of nonempty strings. An empty array is allowed when the pattern matches the resulting path.
- A factory without params allows a call such as home().
- Route groups are stripped from the URL. hash accepts a fragment with or without the leading #.
- Completed . and .. path segments are rejected because URL normalization would change the path.
- The result is a primitive string, so it can cross a Server/Client Component boundary. Import factories and codecs where they are used; their functions cannot be passed as ordinary Client Component props.
- Hrefs exclude the application's base path. Waku's Link, navigation methods, and unstable_redirect apply the base. A native <a href> needs a URL that includes the base.
Client navigation uses the same result:
'use client';
import { useRouter } from 'waku';
import { post } from '../lib/routes';
export const PostButton = ({ slug }: { slug: string }) => {
const router = useRouter();
const href = post({ params: { slug } });
return <button onClick={() => router.push(href)}>Open post</button>;
};router.replace(href), router.prefetch(href), and server-side unstable_redirect(href) also accept factory results. For same-page navigation, bare ?query and #hash strings are resolved against the current URL.
What the types check
Generated routes distinguish raw hrefs from constructed hrefs. Raw strings are checked against the configured concrete href types. A factory result carries a type-only brand recording its normalized route pattern; navigation checks that pattern against the generated route patterns.
Bind factories to registered patterns, not concrete build URLs: for /articles/[slug], use that pattern with { params: { slug: 'intro' } }, or pass the raw string '/articles/intro' directly to navigation. Navigation rejects a factory result for /articles/intro unless that exact pattern is registered. Factory results are assignable to string, but do not have literal URL types.
Parameter names are part of the pattern: /articles/[id] does not match a registered /articles/[slug].
For a static /posts/[slug] route, a factory checks the parameter shape, not whether a slug is among the generated static paths. Constructing an unbuilt slug can therefore succeed and navigate to not found. The brand has no runtime representation and does not register a route.
Without generated route patterns, factory arguments are inferred locally but the pattern's existence is unchecked. A manual RouteConfig.paths augmentation constrains raw hrefs only; see createPages type safety. Widening a factory result to string loses its type evidence, so it may be rejected by navigation in a strictly typed app.
Reading Route Params
useParams_UNSTABLE reads decoded params from the current route. It returns null when the pathname does not match:
'use client';
import { useParams_UNSTABLE as useParams } from 'waku/router/client';
export const PostTitle = () => {
const params = useParams({ from: '/posts/[slug]' });
if (!params) {
return null;
}
return <h1>{params.slug}</h1>;
};Named params are strings and catch-alls are string arrays. Page components already receive params through props; this hook is useful for shared Client Components.
Search Params
A search codec is a plain object with parse and serialize. Keep it in a module shared by the page and its href factory:
// ./src/lib/search.ts
import type { Unstable_SearchCodec } from 'waku/router';
type ProductsSearch = { q: string; page: number };
export const productSearchCodec = {
parse: (query: string): ProductsSearch => {
const params = new URLSearchParams(query);
const page = Number(params.get('page') ?? '1');
if (!Number.isInteger(page) || page < 1) {
throw new Error('Invalid page');
}
return { q: params.get('q') ?? '', page };
},
serialize: ({ q, page }: ProductsSearch): string => {
if (!Number.isInteger(page) || page < 1) {
throw new Error('Invalid page');
}
return new URLSearchParams({ q, page: String(page) }).toString();
},
} satisfies Unstable_SearchCodec<ProductsSearch>;parse receives the incoming query without ?, applies defaults and validation, and returns the typed search value. The server turns a parsing failure into a 400. Client hooks also parse the current query; their errors propagate to the caller or render boundary.
serialize owns outbound validation and returns an encoded query without ?. Its errors propagate when constructing an href or updating search; calling a factory during render can therefore fail the component that renders the link. In development and production, Waku does not parse the serialized output to validate it. If a codec accepts invalid input during serialization, the destination may reject the resulting query.
Construct URLs with search
// ./src/lib/routes.ts
import { unstable_createRouteHref as createRouteHref } from 'waku/router';
import { productSearchCodec } from './search';
export const product = createRouteHref({
path: '/products/[id]',
search: productSearchCodec,
});import { Link } from 'waku';
import { product } from '../lib/routes';
export default function ProductLink() {
return (
<Link
to={product({
params: { id: '42' },
search: { q: 'waku', page: 2 },
})}
>
Product
</Link>
);
}The factory infers search from its codec without a provider, codec ID, or runtime registry. It exposes .path and .searchCodec for the search hooks. A factory without a codec accepts no typed search argument; use a URL string for ad-hoc queries.
search is optional. Omitting it emits no query and calls neither codec function. The destination still parses the incoming empty query and can reject it if its codec requires search values.
Attach the codec to a page
With the file-system router, import the independent codec in getConfig. The generated SearchCodecsConfig supplies the type of PageProps.search; navigation infers types from the factory itself.
// ./src/pages/products/[id].tsx
import type { PageProps } from 'waku/router';
import { productSearchCodec } from '../../lib/search';
export default function ProductPage({
id,
search,
}: PageProps<'/products/[id]'>) {
return (
<p>
Product {id}: {search.q} (page {search.page})
</p>
);
}
export const getConfig = async () =>
({
render: 'dynamic',
unstable_searchCodec: productSearchCodec,
}) as const;With createPages, attach the same codec and declare its page-prop mapping:
// ./src/waku.server.tsx
import { productSearchCodec } from './lib/search';
declare module 'waku/router' {
interface SearchCodecsConfig {
'/products/[id]': typeof productSearchCodec;
}
}
createPage({
render: 'dynamic',
path: '/products/[id]',
component: ProductPage,
unstable_searchCodec: productSearchCodec,
});The factory does not compare its codec with the page configuration; share one independent export to keep them consistent and keep server configuration independent of URL construction. A custom defineRouter resolver can parse its query with this same codec directly; it needs no codec registration.
Read and update search on the client
// ./src/components/pager.tsx
'use client';
import {
useSearch_UNSTABLE as useSearch,
useSetSearch_UNSTABLE as useSetSearch,
} from 'waku/router/client';
import { product } from '../lib/routes';
export const Pager = () => {
const search = useSearch(product);
const setSearch = useSetSearch(product);
if (!search) {
return null;
}
return (
<button onClick={() => setSearch((prev) => ({ page: prev.page + 1 }))}>
page {search.page}
</button>
);
};Both hooks also accept a { path, searchCodec } descriptor. They infer search from the supplied codec, not from SearchCodecsConfig. Generated route types reject unknown patterns, ignoring route groups. Pass descriptors inline or preserve a named descriptor's literal path with as const.
- useSearch returns the parsed search, or null for a different pathname.
- The setter is a no-op for a different pathname. Otherwise, it parses the current query, shallow-merges a partial value or updater result, serializes, and navigates.
- Updates preserve the pathname and fragment, default to push and no scrolling, and accept { history: 'replace', scroll: true } as the second argument.
- Parsing, serialization, and navigation errors reject the setter's promise. There is no parse check of its newly serialized output.
- Parser defaults participate in the merge. Have the serializer omit defaults if you do not want them written into the URL.
Using a validation library
A schema library can implement both codec operations. For example, with Zod:
// ./src/lib/search.ts
import { z } from 'zod';
import type { Unstable_SearchCodec } from 'waku/router';
const schema = z.object({
q: z.string().default(''),
page: z.coerce.number().int().min(1).default(1),
});
type ProductsSearch = z.infer<typeof schema>;
export const productSearchCodec = {
parse: (query: string): ProductsSearch =>
schema.parse(Object.fromEntries(new URLSearchParams(query))),
serialize: (search: ProductsSearch): string => {
const { q, page } = schema.parse(search);
return new URLSearchParams({ q, page: String(page) }).toString();
},
} satisfies Unstable_SearchCodec<ProductsSearch>;The schema validates inbound queries and outbound values independently. Query-state libraries such as nuqs can also supply parsers and serializers through an isomorphic module such as nuqs/server; return a query without its leading ? and implement any required outbound validation in serialize. Waku's hooks do not require the library's client provider.
Migration
The typed-navigation API changes are breaking, including for unstable exports.
-
Replace structured to targets with a local factory call. Pass its string result to Link, push, replace, prefetch, or unstable_redirect:
import { unstable_createRouteHref as createRouteHref } from 'waku/router'; const post = createRouteHref({ path: '/posts/[slug]' }); <Link to={post({ params: { slug } })}>Post</Link>; router.push(post({ params: { slug } }));This replaces to={{ to: '/posts/[slug]', params: { slug } }} and router.push({ to: '/posts/[slug]', params: { slug } }). Plain string targets do not change. For link wrappers, use LinkProps without a route-pattern type argument.
-
Remove SearchCodecsProvider_UNSTABLE and codec id fields. Import the codec into the factory via search: codec, and into the page configuration via unstable_searchCodec: codec.
-
Replace useSearch({ from: '/products/[id]' }) and useSetSearch({ from: '/products/[id]' }) with useSearch(product) and useSetSearch(product), or pass { path: '/products/[id]', searchCodec }. useParams({ from }) is unchanged.
-
Remove getSearchCodecs from unstable_defineRouter. Keep the shared codec wherever the resolver parses incoming queries. Generated or manual SearchCodecsConfig mappings remain for PageProps.search, not client navigation.
-
Replace unstable_buildRouteHref from waku/router/client-core with unstable_createRouteHref from waku/router. The factory takes the codec directly and needs no resolver callback. The provider helpers unstable_isCodec and Unstable_AnyCodec are removed; use the Unstable_SearchCodec<Search> contract for a known search type.

