Cookbook Router
Practical Patterns

Rendering adapter

Convert opaque route views into renderer output with renderRouteMatch().

The core router resolves what should render. A rendering adapter decides how it renders.

renderRouteMatch() traverses a matched branch and calls your callbacks for route views, layout views, slots, intercepts, boundaries, and empty states.

Use opaque route views

Core route views can be anything. A minimal adapter can use string keys and a registry.

import {
  defineRoutes,
  renderRouteMatch,
  type RouteMatch,
  type RouteViewContext,
} from '@cookbook/router';

interface Node {
  readonly kind: string;
  readonly routeId?: string;
  readonly children?: readonly Node[];
}

type ViewKey =
  | 'app.layout'
  | 'home.page'
  | 'not-found.page';

const routes = defineRoutes([
  {
    id: 'app',
    path: '/',
    layout: {
      view: 'app.layout',
    },
    children: [
      {
        id: 'home',
        index: true,
        view: 'home.page',
      },
    ],
  },
] as const);

The router never calls those strings. Your adapter does.

Convert views into output

const views: Record<
  ViewKey,
  (context: RouteViewContext<Node | null>) => Node
> = {
  'home.page': (context) => ({
    kind: 'home',
    routeId: context.match.id,
  }),
  'app.layout': (context) => ({
    kind: 'app-layout',
    routeId: context.match.id,
    children: [
      context.outlet,
      ...Object.values(context.slots),
    ].filter((child): child is Node => child !== null),
  }),
  'not-found.page': () => ({
    kind: 'not-found',
  }),
};

export function renderApp(
  match: RouteMatch | null,
): Node | null {
  return renderRouteMatch<ViewKey, Node | null>(match, {
    fallback: null,
    renderView(view, context) {
      return views[view](context);
    },
    renderLayout(view, context) {
      return views[view](context);
    },
    renderEmpty(context) {
      if (context.reason === 'not-found') {
        return views['not-found.page']({
          match: context.match ?? {
            id: 'not-found',
            route: {} as never,
            params: {},
          },
          outlet: null,
          slots: {},
        });
      }

      return null;
    },
  });
}

This is not a UI recommendation. It is the smallest useful adapter shape: opaque route views in, renderer output out.

Add boundaries

renderBoundary() receives the current rendered content plus loading/error ownership metadata.

function renderWithBoundaries(
  match: RouteMatch | null,
): Node | null {
  return renderRouteMatch<ViewKey, Node | null>(match, {
    fallback: null,
    renderView: (view, context) => views[view](context),
    renderLayout: (view, context) => views[view](context),
    renderBoundary(content, context) {
      return {
        kind: 'boundary',
        routeId: context.match.id,
        children: content ? [content] : [],
      };
    },
    renderEmpty: () => null,
  });
}

The core router does not install a framework error boundary or Suspense boundary. It gives the adapter the ownership data needed to install one.

Where this bites

Do not call arbitrary views blindly

A route view might be a string, component, lazy wrapper, server template, module reference, or native screen descriptor. Only the adapter knows what conversion means.

Empty states need policy

Without renderEmpty(), every empty state returns fallback. Page not-found and empty slot are different UI decisions.

Boundaries are adapter work

renderRouteMatch() does not catch framework render errors. It passes fallback ownership to renderBoundary() so the adapter can install the right boundary.

On this page