;
```
`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.