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 RootLayout → ProductsLayout → ProductPage.
// 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.
| Pattern | Pathname | Params | Note |
|---|---|---|---|
/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/b | null (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/* | /ja | null (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.
| Written | Error |
|---|---|
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}`;| Type | Meaning |
|---|---|
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 |
RoutesRecord | The type of a table: keys are strings starting with / |
RouteNode | A value in the table: a RouteComponent or a branch |
RouteComponent | The type of a leaf or layout: ComponentType<never> |
Match | What match returns: pattern, params and stack (outer-first, the leaf last) |
RouteOf is what @k8ordo/state’s Register uses for typed paths. Links & location