Typed Routes

Type-safe navigation to dynamic routes with router.push and router.replace.

  • Experimental

Overview

Waku's router provides type-safe routing helpers layered on top of the string-based APIs. The string APIs keep working; the typed helpers are additive.

Typed Navigation

router.push() and router.replace() accept a structured target, so you can navigate to a dynamic route without interpolating slugs by hand. The params are type-checked against the route pattern:

'use client';

import { useRouter } from 'waku';

export const PostButton = ({ slug }: { slug: string }) => {
  const router = useRouter();

  return (
    <button
      onClick={() =>
        router.push({
          to: '/posts/[slug]',
          params: { slug },
          hash: 'top',
        })
      }
    >
      Open post
    </button>
  );
};
  • to is a route pattern (e.g. /posts/[slug], /docs/[...path]). params is required when the pattern has slugs and is typed from it; a catch-all takes a string[].
  • hash is optional. search is typed per route by a search codec (see Search Params).
  • The plain string form (router.push('/posts/hello')) keeps working.
  • For same-page navigation, a bare #hash or ?query (e.g. router.push('#section') or router.push('?tab=2')) is resolved against the current URL; a #hash also scrolls to that element.

Reading Route Params

useParams() reads the current route's params, typed from the route pattern you pass. It returns null when the current path does not match that pattern, so it is safe to call from a component rendered under more than one route.

'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>;
};
  • The result is typed from the pattern: /posts/[slug] gives { slug: string }, and a catch-all like /docs/[...path] gives { path: string[] }.
  • Values are URL-decoded, mirroring how router.push encodes them.
  • It re-renders when the current route path changes.
  • Page components already receive their params through props; useParams() is for client and shared components that do not.

Search Params

Waku types the URL search params (the query string) per route with a search codec you provide. The codec converts the query string to a typed object and back, so server components, client hooks, and navigation share one typed shape. Waku ships the contract; you bring the implementation (hand-written, or wrapping a library).

Define a codec

A codec is a plain object with a stable id, a parse, and a serialize:

// ./src/lib/search.ts
import type { Unstable_SearchCodec } from 'waku/router';

type ProductsSearch = { q: string; page: number };

export const searchCodec = {
  id: 'search',
  parse: (query: string): ProductsSearch => {
    const params = new URLSearchParams(query);
    return { q: params.get('q') ?? '', page: Number(params.get('page')) || 1 };
  },
  serialize: ({ q, page }: ProductsSearch) =>
    new URLSearchParams({ q, page: String(page) }).toString(),
} satisfies Unstable_SearchCodec<ProductsSearch>;

parse may throw to reject a malformed query; Waku turns that into a 400.

satisfies Unstable_SearchCodec<ProductsSearch> validates the codec where you define it. You can drop it (and the import) if you annotate parse/serialize with your ProductsSearch type yourself; the route still type-checks the codec when you attach it.

Wrapping a validation library

A codec is just { id, parse, serialize }, so you can back it with a schema or query-state library instead of hand-writing the logic. Use the library for parse (validation and coercion) and let it (or URLSearchParams) build the query string in serialize.

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(''),
  // `.default` only fills a missing value; a bad value throws (-> 400).
  // swap it for `.catch(1)` to fall back to 1 instead
  page: z.coerce.number().int().min(1).default(1),
});

type ProductsSearch = z.infer<typeof schema>;

export const searchCodec = {
  id: 'search',
  parse: (query: string): ProductsSearch =>
    schema.parse(Object.fromEntries(new URLSearchParams(query))),
  serialize: (search: ProductsSearch): string =>
    new URLSearchParams({
      q: search.q,
      page: String(search.page),
    }).toString(),
} satisfies Unstable_SearchCodec<ProductsSearch>;

With nuqs (its nuqs/server entry has no 'use client', so the codec stays isomorphic):

// ./src/lib/search.ts
import {
  createLoader,
  createSerializer,
  parseAsInteger,
  parseAsString,
} from 'nuqs/server';
import type { Unstable_SearchCodec } from 'waku/router';

const parsers = {
  q: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1),
};

const loadSearch = createLoader(parsers);
const serializeSearch = createSerializer(parsers);

type ProductsSearch = { q: string; page: number };

export const searchCodec = {
  id: 'search',
  // nuqs loaders don't validate by default; `{ strict: true }` throws on a
  // bad value (-> 400). drop it to fall back to the parser defaults instead
  parse: (query: string): ProductsSearch =>
    loadSearch(new URLSearchParams(query), { strict: true }),
  // createSerializer prepends "?"; the codec returns the query without it
  serialize: (search: ProductsSearch): string =>
    serializeSearch(search).replace(/^\?/, ''),
} satisfies Unstable_SearchCodec<ProductsSearch>;
Note

Using nuqs here differs from its own server-side approach. With Waku, nuqs is only the parser/serializer (nuqs/server); the codec is registered with the router by id, so Waku runs parse as part of the request (typed props.search, same codec on the client). You don't wire up nuqs's own server pieces like createSearchParamsCache or NuqsAdapter / useQueryState.

Attach the codec to a route

Set it on the route's config, and map the route to the codec for typing.

With createPages, pass unstable_searchCodec and declare the mapping:

import { searchCodec } from './lib/search';

declare module 'waku/router' {
  interface SearchCodecsConfig {
    '/products': typeof searchCodec;
  }
}

// inside createPages(...)
createPage({
  render: 'dynamic',
  path: '/products',
  component: ProductsPage,
  unstable_searchCodec: searchCodec,
});

With the file-system router, export it from getConfig and the types are generated for you:

// ./src/pages/products.tsx
import { searchCodec } from '../lib/search';

export const getConfig = async () => ({
  render: 'dynamic',
  unstable_searchCodec: searchCodec,
});

Read search on the server

The page component receives a typed search prop:

import type { PageProps } from 'waku/router';

export default function ProductsPage({ search }: PageProps<'/products'>) {
  return (
    <p>
      {search.q} (page {search.page})
    </p>
  );
}

Provide codecs on the client

To read, update, or navigate with search on the client, the codec has to be available there. Wrap your app with Unstable_SearchCodecsProvider. Because codecs hold functions, the provider must be rendered from a 'use client' module that imports them:

// ./src/components/search-codecs.tsx
'use client';

import type { ReactNode } from 'react';
import { Unstable_SearchCodecsProvider } from 'waku/router/client';
import * as searchCodecs from '../lib/search';

export const SearchCodecs = ({ children }: { children: ReactNode }) => (
  <Unstable_SearchCodecsProvider searchCodecs={searchCodecs}>
    {children}
  </Unstable_SearchCodecsProvider>
);

Pass only codecs. import * as works when the module exports just codecs (types are erased); if the module also has non-codec exports, list the codecs explicitly instead, e.g. searchCodecs={[searchCodec]}. Non-codec values are ignored with a warning.

Render it in your root layout so it wraps every page:

// ./src/pages/_layout.tsx (fs-router root layout)
import type { ReactNode } from 'react';
import { SearchCodecs } from '../components/search-codecs';

export default function RootLayout({ children }: { children: ReactNode }) {
  return <SearchCodecs>{children}</SearchCodecs>;
}

With createPages, wrap your root layout (or root) component's children the same way.

Note

Provide codecs app-wide, not per route. A codec must be available on the page you navigate from. If a link on your home page does push({ to: '/products', search }), the home page needs /products's codec to serialize the URL, so a provider only around /products is not enough. Wrapping your root layout covers every page.

Read and update search on the client

'use client';

import {
  useSearch_UNSTABLE as useSearch,
  useSetSearch_UNSTABLE as useSetSearch,
} from 'waku/router/client';

export const Pager = () => {
  const search = useSearch({ from: '/products' });
  const setSearch = useSetSearch({ from: '/products' });
  if (!search) {
    return null;
  }
  return (
    <button onClick={() => setSearch((prev) => ({ page: prev.page + 1 }))}>
      page {search.page}
    </button>
  );
};
  • useSearch returns the typed search, or null when the current path does not match from.
  • setSearch takes a partial or an updater, serializes with the codec, and navigates (push by default, or { history: 'replace' }). Like useSearch, it's a no-op when the current path does not match from (or that route has no codec).

push, replace, and Link accept a typed search for the target route:

router.push({ to: '/products', search: { q: 'waku', page: 1 } });

<Link to={{ to: '/products', search: { q: 'waku', page: 1 } }}>Products</Link>;

A route without a codec takes no search (its search is never); use the plain string form (push('/foo?ref=home')) for ad-hoc queries.

designed bycandycode alternative graphic design web development agency San Diego