Cookbook Router
Getting Started

Quick start

Define routes, create a router, render React, and generate contracts.

This guide creates a typed React app with route definitions, generated contracts, typed links, and a not-found fallback using pnpm and direct CLI generation. For a framework-agnostic implementation using only @cookbook/router, see Core quick start. For npm, Yarn, Bun, non-React runtimes, bundler plugins, and project initialization, see Installation.

Install

pnpm add @cookbook/router @cookbook/router-react react react-dom
pnpm add -D @cookbook/router-cli

@cookbook/router-react declares React as a peer dependency, so the app must install react and react-dom.

This guide uses the CLI directly to keep the generation flow visible. In a production project, you can run the same generation and validation pipeline through a bundler plugin.

Define routes

Create src/routes.ts.

import { defineRoutes } from '@cookbook/router';
import { HomePage, RootLayout, UserPage, NotFoundPage } from './pages';

export const routes = defineRoutes([
  {
    id: 'root',
    path: '/',
    layout: {
      view: RootLayout,
    },
    children: [
      {
        id: 'home',
        index: true,
        view: HomePage,
      },
      {
        id: 'users.show',
        path: 'users/{id:int}',
        search: {
          tab: { type: 'string', optional: true },
        },
        hash: { type: 'enum', values: ['profile', 'settings', 'security'], optional: true },
        view: UserPage,
        meta: {
          title: 'User',
          requiresAuth: true,
        },
      },
      {
        id: 'not-found',
        path: '{*path}',
        view: NotFoundPage,
        meta: {
          title: 'Not found',
        },
      },
    ],
  },
] as const);

Use as const so route IDs, paths, hash values, URL options, and metadata remain literal enough for the CLI to generate useful contracts. Route files consumed by the CLI should use static descriptors, not URLKit runtime builders.

Create pages and layout

Create src/pages.tsx.

import { Link, Outlet, useParams, useSearchParams, useHashParams } from '@cookbook/router-react';

export function RootLayout() {
  return (
    <main>
      <nav>
        <Link to="home">Home</Link>
        <Link to="users.show" params={{ id: 42 }} search={{ tab: 'settings' }} hash="profile">
          User 42
        </Link>
      </nav>
      <Outlet />
    </main>
  );
}

export function HomePage() {
  return <h1>Home</h1>;
}

export function UserPage() {
  const params = useParams('users.show');
  const search = useSearchParams('users.show');
  const hash = useHashParams('users.show');

  return (
    <article>
      <h1>User {params.id}</h1>
      <p>Tab: {search.tab ?? 'none'}</p>
      <p>Hash: {hash ?? 'none'}</p>
    </article>
  );
}

export function NotFoundPage() {
  return <h1>Not found</h1>;
}

Create the router

Create src/router.ts.

import { createRouter } from '@cookbook/router';
import { routes } from './routes';

export const router = createRouter({
  routes,
});

The default router chooses browser history when window exists and memory history otherwise. You can pass basename, middleware, lifecycle, pathOptions, or a custom history when needed. If route definitions use custom path constraints, pass pathConstraints to defineRoutes() so immediate validation can see them. See Path routes and constraints for built-in constraints and custom constraints.

Render React

Create src/main.tsx.

import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@cookbook/router-react';
import { router } from './router';

createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);

RouterProvider starts the router automatically by default. Initial redirects, canonical URL cleanup, provider middleware, and lifecycle hooks resolve through the provider-owned startup path. Use router.refresh() later when you intentionally need to re-resolve the current location after a runtime condition changes.

Generate contracts

Add scripts to package.json.

{
  "scripts": {
    "generate:routes": "cookbook-router generate --routes src/routes.ts --out-dir .cookbook-router",
    "validate:routes": "cookbook-router validate --routes src/routes.ts"
  }
}

Run generation:

pnpm generate:routes

Add the generated contract and registration files to tsconfig.json.

{
  "include": ["src", ".cookbook-router/contracts.ts", ".cookbook-router/register.d.ts"]
}

Once .cookbook-router/register.d.ts is included in your TypeScript program, it augments @cookbook/router and @cookbook/router-react with the generated route contracts from .cookbook-router/contracts.ts.

Router APIs can then infer valid route IDs, exact route paths, path params, search values, hash values, and route metadata from the generated public types.

Path params follow the generated constraint contract:

  • numeric built-in constraints such as {id:int}, {price:decimal}, {value:range(1,10)}, {value:min(1)}, and {value:max(10)} become number;
  • unconstrained params, string-shaped constraints such as uuid, regex, list, minlength, maxlength, and custom constraints are exposed as string unless combined with a numeric built-in constraint.
  • Wildcards such as {*path} are parsed as readonly string[]; generated route URL input accepts string | readonly string[] for wildcard params.

Use typed navigation

import { useNavigate } from '@cookbook/router-react';

export function OpenSettingsButton() {
  const navigate = useNavigate();

  return (
    <button
      type="button"
      onClick={() => {
        void navigate.to({
          route: 'users.show',
          params: { id: 42 },
          search: { tab: 'settings' },
          hash: 'security',
        });
      }}
    >
      Open settings
    </button>
  );
}

A two-argument variant is also supported:

await navigate.to('users.show', {
  params: { id: 42 },
});

Router-level fallback

RouterProvider.fallback is the last-resort UI for unmatched locations that do not resolve to any route.

<RouterProvider router={router} fallback={<h1>Not found</h1>} />

Use it for simple apps, prototypes, tests, or as a defensive fallback while route definitions are still incomplete.

For production route handling, prefer an explicit catch-all route:

{
  id: 'not-found',
  path: '{*path}', 
  view: NotFoundPage,
}

A catch-all route participates in normal Cookbook Router matching. Static and dynamic routes rank above catch-all wildcards, so /overview and /users/{id} are checked before a root /{*path} not-found route. That means it can use the same routing features as any other route, including layouts, middleware, redirects, rewrites, lifecycle hooks, metadata, slots, and generated contracts.

You can also place catch-all routes under specific layouts or route groups to show different not-found pages in different parts of the app, example:

{
  id: 'dashboard',
  path: '/dashboard',
  layout: {
    view: DashboardLayout,
  },
  children: [
    {
      id: 'dashboard.home',
      index: true,
      view: DashboardHomePage,
    },
    {
      id: 'dashboard.not-found',
      path: '{*path}',
      view: DashboardNotFoundPage,
    },
  ],
}

In this example, unknown /dashboard/* URLs keep DashboardLayout mounted while DashboardNotFoundPage renders inside it.

Run locally

pnpm generate:routes
pnpm dev

When working inside this monorepo, rebuild package outputs before running examples after changing package source:

pnpm build:packages
pnpm --filter react-blog dev

Next steps

On this page