--- slug: typed-routes title: Typed Routes description: Build route URLs with locally inferred params and search codecs. category: Routing and Navigation order: 11 tags: [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: `` and `router.push('/about')` work without one. The APIs in this guide are unstable and may change. For upgrading existing typed navigation, see [Migration](#migration). ## Build Route Hrefs Define a factory in a module that both Server and Client Components can import: ```ts // ./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: ```tsx import { Link } from 'waku'; import { docs, home, post } from '../lib/routes'; export default function Navigation() { return ( ); } ``` - `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 `` needs a URL that includes the base. Client navigation uses the same result: ```tsx '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 ; }; ``` `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](/create-pages#router-paths-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: ```tsx '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

{params.slug}

; }; ``` 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: ```ts // ./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; ``` `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 ```ts // ./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, }); ``` ```tsx import { Link } from 'waku'; import { product } from '../lib/routes'; export default function ProductLink() { return ( Product ); } ``` 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. ```tsx // ./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 (

Product {id}: {search.q} (page {search.page})

); } export const getConfig = async () => ({ render: 'dynamic', unstable_searchCodec: productSearchCodec, }) as const; ``` With `createPages`, attach the same codec and declare its page-prop mapping: ```tsx // ./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 ```tsx // ./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 ( ); }; ``` 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](https://zod.dev): ```ts // ./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; 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; ``` The schema validates inbound queries and outbound values independently. Query-state libraries such as [nuqs](https://nuqs.dev) 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. 1. Replace structured `to` targets with a local factory call. Pass its string result to `Link`, `push`, `replace`, `prefetch`, or `unstable_redirect`: ```tsx import { unstable_createRouteHref as createRouteHref } from 'waku/router'; const post = createRouteHref({ path: '/posts/[slug]' }); Post; 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. 2. 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`. 3. 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. 4. 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. 5. 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` contract for a known search type.