@k8ordo/router

Route table

The route table lists every pathname the application answers, in one place. This page covers the table’s grammar, the order it matches in, what is refused as soon as the table is defined, the error boundaries you write into it, and the types that come out of it.

Leaves and branches

A value in the table is one of two things. A leaf is the component to render; a branch is { layout?, error?, children }, where children is a table of the same shape whose keys continue the parent’s pattern.

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { lazy } from 'react';

import { Home } from './pages/home';
import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { ProductsLayout } from './products-layout';
import { RootLayout } from './root-layout';

const Settings = lazy(() => import('./pages/settings'));

export const routes = defineRoutes({
  '/': {
    layout: RootLayout,
    children: {
      '/': Home,
      '/products': {
        layout: ProductsLayout,
        children: {
          '/': ProductList,
          '/:id': ProductPage,
        },
      },
      '/settings': Settings,
    },
  },
});

A child key of / is the branch’s own index page. A branch at the root key / adds nothing to the URL, which makes its layout the one around every page.

A match is a stack: the layouts outer-first, then the leaf. Each layout renders the next element through <Outlet />, so /products/42 nests as RootLayoutProductsLayoutProductPage.

// src/root-layout.tsx
import { Outlet } from '@k8ordo/router';
import { Suspense } from 'react';

export function RootLayout() {
  return (
    <>
      <header>Shop</header>
      <Suspense fallback={<p>Loading…</p>}>
        <Outlet />
      </Suspense>
    </>
  );
}
// src/products-layout.tsx
import { Outlet } from '@k8ordo/router';

export function ProductsLayout() {
  return (
    <section>
      <h1>Products</h1>
      <Outlet />
    </section>
  );
}

<Router> passes no props to leaves or layouts; params are read with useParams. The component type the table accepts, RouteComponent, is ComponentType<never>, so a component the framework does pass props to fits the same table.

A React.lazy component works as a leaf. A branch is recognised by its children key, so a lazy component — an object, not a function — is never mistaken for one. Put a <Suspense> in a layout above it so there is somewhere to fall back to while the chunk arrives. The fallback shows on the first render and on a navigation that newly mounts that <Suspense>; when the page changes under a <Suspense> already on screen, the background render keeps the previous page instead.

Pattern grammar

Patterns are matched as URLPattern pathnames. The table uses literal segments, :name, a trailing /*, and the group /(name), which never appears in the URL.

PatternPathnameParamsNote
/products/products/{}A trailing slash is the same pathname
/products/:id/products/42{ id: '42' }:name captures one segment
/products/:id/products/a%2Fb{ id: 'a/b' }The value is decoded
/products/:id/products/a/bnull (No match)It never spans a /
/products/:id/products/null (No match)An empty segment does not match
/:locale/*/ja/no/such/page{ locale: 'ja' }What the wildcard captured is not a param
/:locale/*/janull (No match)It does not match the part before /* on its own
'/(docs)' › '/guide'/guide{}A group adds no URL segment

:param

:name captures one non-empty segment and hands it back decoded with decodeURIComponent; a spelling that cannot be decoded is kept as written. The type is inferred from the pattern string: the params of /:locale/products/:id are { locale: string; id: string }.

Wildcards: /*

/* takes what nothing before it matched — not one segment but anything below. It is something to match, never something to link to, so href and navigateTo refuse it at run time with a TypeError, and in the types as well once Register is declared.

At the root, /* matches / itself as well — one more reason it goes last.

Groups: /(name)

A group structures the table — its own layout, its own subtree — without adding a URL segment. / can appear only once in an object, so two sections at the same depth could not otherwise have different layouts.

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { DocsLayout } from './docs-layout';
import { MarketingLayout } from './marketing-layout';
import { Guide } from './pages/guide';
import { Home } from './pages/home';
import { Pricing } from './pages/pricing';

export const routes = defineRoutes({
  '/(marketing)': {
    layout: MarketingLayout,
    children: {
      '/': Home,
      '/pricing': Pricing,
    },
  },
  '/(docs)': {
    layout: DocsLayout,
    children: {
      '/guide': Guide,
    },
  },
});

In this table /pricing renders inside MarketingLayout and /guide inside DocsLayout, and neither marketing nor docs appears in the URL. A group can sit inside a branch as well.

Order is the rule

Matching walks the table top to bottom and takes the first pattern that fits. Precedence is what you wrote; there is no specificity ranking to reason backwards from, so the table reads like the code it is.

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { NewProduct } from './pages/new-product';
import { ProductPage } from './pages/product-page';

export const routes = defineRoutes({
  '/products/:id': ProductPage,
  '/products/new': NewProduct,
});

In this order /products/new matches ProductPage, with id set to new.

// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { NewProduct } from './pages/new-product';
import { ProductPage } from './pages/product-page';

export const routes = defineRoutes({
  '/products/new': NewProduct,
  '/products/:id': ProductPage,
});

With the literal segment first, /products/new reaches NewProduct and every other /products/… reaches ProductPage.

Reading the table directly

The Routes object defineRoutes returns has one operation, match(pathname, accept?). It returns a Match — the winning pattern, its params and its stack — or null when nothing fits. It needs no browser, so a test can assert a table’s shape and precedence directly.

// src/routes.test.ts
import type { Match } from '@k8ordo/router';
import { expect, it } from 'vitest';

import { routes } from './routes';

it('sends /products/new to its own page, not to :id', () => {
  expect(routes.match('/products/new')?.pattern).toBe('/products/new');
  expect(routes.match('/products/42')?.params).toStrictEqual({ id: '42' });
  expect(routes.match('/nowhere')).toBeNull();
});

it('walks on when the caller declines a fit', () => {
  const onlyNumericIds = (found: Match) =>
    found.pattern !== '/products/:id' ||
    /^\d+$/u.test(found.params['id'] ?? '');

  expect(routes.match('/products/7', onlyNumericIds)?.pattern).toBe(
    '/products/:id',
  );
  expect(routes.match('/products/shoes', onlyNumericIds)).toBeNull();
});

accept lets the caller decline a fit: when it returns false, the walk goes on as if that pattern had not matched. The framework uses it so that a param a schema refuses is a pathname the pattern does not answer.

What fails at definition time

A table is checked when its module loads, not when someone first navigates to the page. Every one of these is a TypeError.

WrittenError
A key that does not start with /{ 'products': Products }route pattern "products" must start with "/"
Parentheses that are not exactly a group{ '/(admin)/new': NewItem }route group "/(admin)/new" must be "/(name)" and nothing else — a regular expression is not part of the grammar
A group without children{ '/(oops)': Home }route group "/(oops)" must have children
The same full pattern twice, wherever the copies nest{ '/x': A, '/': { children: { '/x': B } } }route pattern "/x" is declared twice
A pattern URLPattern cannot parse{ '/a{b': Home }URLPattern’s own TypeError

Parentheses are refused because URLPattern reads (…) as a regular-expression group: /(admin)/new would quietly match /admin/new and capture a nameless param. In the grammar, parentheses mean a group and nothing else. A group must have children because a leaf that adds no segment would be a second declaration of the parent’s index.

Error boundaries

A branch can name an error beside its layout. When anything below throws while rendering, the error component renders in the layout’s hole instead, and the frame the layout draws survives.

// src/products-error.tsx
import type { ErrorProps } from '@k8ordo/router';
import { href } from '@k8ordo/router';

export function ProductsError({ error, reset }: ErrorProps) {
  return (
    <div role="alert">
      <p>{error instanceof Error ? error.message : 'Something went wrong'}</p>
      <button onClick={reset} type="button">
        Try again
      </button>
      <a href={href('/products')}>Back to the list</a>
    </div>
  );
}
// src/routes.ts
import { defineRoutes } from '@k8ordo/router';

import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { ProductsError } from './products-error';
import { ProductsLayout } from './products-layout';

export const routes = defineRoutes({
  '/products': {
    layout: ProductsLayout,
    error: ProductsError,
    children: {
      '/': ProductList,
      '/:id': ProductPage,
    },
  },
});

The component receives ErrorProps{ error, reset } — and its type is ErrorComponent. error is whatever was thrown, typed unknown. reset() renders the subtree again in place; if it throws again, the error component comes back.

Leaving the page that failed leaves the failure behind. The boundary is keyed by NavigationGeneration — a number that changes each time a new tree is applied — so it is recreated when another page arrives. It is not keyed by the pathname because the URL commits before the tree does. A state change that only moves the search changes no tree, so the failure stays.

The boundary sits inside the layout, so a throw from the layout itself is not caught by its own branch’s error; it reaches the error of a branch further out. With no boundary anywhere, the error leaves <Router>.

The boundary also wraps what is below in a <Suspense> whose fallback is null, so that a subtree which throws during a server render is left for the browser. A React.lazy page under a branch that names error therefore renders nothing inside that boundary while its chunk loads on the first render or on a navigation that enters that branch, instead of reaching a <Suspense> in a layout above. To show a fallback, put the <Suspense> in a layout below that branch, or wrap the lazy component in one directly.

Under @k8ordo/static and @k8ordo/server, error.tsx becomes this same error in the generated table. Under the framework

Types derived from the table

The table’s types come from inferring the pattern strings alone, with no code generation. With routes as the table built in Get Started (/, /products, /products/:id, /*), the three types resolve to what the second block shows.

import type {
  NavigablePatternOf,
  PatternOf,
  RouteOf,
} from '@k8ordo/router';

import type { routes } from './routes';

type Pattern = PatternOf<typeof routes.record>;
type Linkable = NavigablePatternOf<typeof routes.record>;
type Path = RouteOf<typeof routes>;
type Pattern = '/' | '/products' | '/products/:id' | '/*';
type Linkable = '/' | '/products' | '/products/:id';
type Path = '/' | '/products' | `/products/${string}`;
TypeMeaning
PatternOf<R>Every leaf pattern in the table, as written
NavigablePatternOf<R>The patterns a link can point at: the wildcards excluded
RouteOf<typeof routes>The table’s pathname space: the linkable patterns as a union, with any string where each :param was
Routes<R>What defineRoutes returns: kind, record (the table as passed) and match
RoutesRecordThe type of a table: keys are strings starting with /
RouteNodeA value in the table: a RouteComponent or a branch
RouteComponentThe type of a leaf or layout: ComponentType<never>
MatchWhat match returns: pattern, params and stack (outer-first, the leaf last)

RouteOf is what @k8ordo/state’s Register uses for typed paths. Links & location