Creating a Route
Route fields, nesting, layouts, redirects, URL state, and validation.
Routes describe URL matching, route identity, rendering hierarchy, metadata, redirects, slots, intercepts, middleware, lifecycle hooks, and generated contract inputs. URL state is parsed and built by @cookbook/urlkit; Cookbook Router owns the route tree and routing behavior around the URL state.
Core and React usage
Route definitions are core router data. The view field is opaque to @cookbook/router.
Core examples can use strings, symbols, objects, or renderer-owned handles as route views.
import { defineRoutes } from '@cookbook/router';
export const routes = defineRoutes([
{
id: 'home',
path: '/',
view: 'home-screen',
},
{
id: 'users.show',
path: '/users/{id:int}',
view: 'user-screen',
},
] as const);The core router matches and navigates. Your adapter decides how 'home-screen' becomes UI.
React examples usually pass components as route views and render them through RouterProvider.
import { defineRoutes } from '@cookbook/router';
import { RouterProvider } from '@cookbook/router-react';
export const routes = defineRoutes([
{
id: 'home',
path: '/',
view: HomePage,
},
{
id: 'users.show',
path: '/users/{id:int}',
view: UserPage,
},
] as const);
<RouterProvider router={router} />;React is an adapter on top of the same route definitions. It is not a different route model.
Route definition
The current public route shape is:
interface RouteDefinition {
readonly id: string;
readonly path?: string;
readonly index?: boolean;
readonly view?: RouteView;
readonly layout?: RouteLayoutDefinition;
readonly children?: readonly RouteDefinition[];
readonly intercepts?: RouteIntercepts;
readonly redirect?: RouteRedirect;
readonly search?: RouteSearchSchema;
readonly hash?: RouteHashSchema;
readonly url?: RouterUrlOptions;
readonly meta?: RouteMeta;
readonly loading?: RouteView;
readonly error?: RouteView;
readonly lifecycle?: RouteLifecycle;
readonly middleware?: readonly Middleware[];
}Supporting shapes:
interface RouteLayoutDefinition {
readonly view?: RouteView;
readonly slots?: RouteSlotDefinitions;
}
type RouteSlotDefinitions = Readonly<Record<string, RouteSlotDefinition>>;
type RouteSlotDefinition = RouteView | RouteSlotConfig | true;
interface RouteSlotConfig {
readonly view?: RouteView;
readonly routes?: readonly RouteDefinition[];
readonly meta?: RouteMeta;
}
type RouteRedirect =
| string
| {
readonly route: string;
readonly params?: Record<string, unknown>;
readonly search?: Record<string, unknown>;
readonly hash?: string | null;
};
interface RouteInterceptConfig {
readonly to: readonly string[];
readonly view: RouteView;
}
type RouteIntercepts = Readonly<Record<string, RouteInterceptConfig>>;Field reference
| Field | Purpose |
|---|---|
id | Required stable route ID. Used for navigation, contracts, diagnostics, and metadata lookup. |
path | URL pattern. Child paths are relative to the parent path. |
index | Marks a child route as the default route for its parent path. Index routes must not define path. |
view | Page view rendered for this route. |
layout.view | Layout wrapper view. Layouts render child branches through <Outlet />. |
layout.slots | Named layout regions rendered through <Slot name="..." />. |
children | Primary child route branch. |
intercepts | Configured source-route interception rules keyed by target slot name. |
redirect | Internal or external redirect target. Redirect-only routes do not need views. |
search | URLKit-backed Router static search descriptor for parsed search state and generated contracts. |
hash | URLKit-backed static hash object descriptor for parsed hash state and generated contracts. |
url | Route-level URL options such as arrayFormat, defaults, invalidSearch, invalidHash, and unknownSearch; overrides router-level defaults for this route. |
meta | Arbitrary metadata preserved in generated contracts and runtime route definitions. |
loading | Route-level React Suspense fallback view rendered while the route subtree is loading. |
error | Route-level React error fallback view rendered when the route subtree throws during rendering. |
lifecycle | Route-level transition hooks. |
middleware | Route-level middleware. |
Path composition
Child paths are relative by default.
{
id: 'users',
path: '/users',
children: [
{
id: 'users.show',
path: '{id:int}',
view: UserPage,
},
],
}Resolved path:
/users/{id:int}Child paths may include a leading /, but they are still composed relative to the parent route. This lets route files use either terms-of-service or /terms-of-service for the same nested URL segment.
{
id: 'policies',
path: '/policies',
children: [
{
id: 'terms-of-service',
path: '/terms-of-service',
view: TermsOfServicePage,
},
],
}Attention
terms-of-service matches /policies/terms-of-service, not /terms-of-service.
Top-level routes can still use leading / normally.
Path routes and constraints
Route path values use PathKit syntax and URLKit parsed-param semantics. PathKit owns the route-pattern grammar and constraint validation; URLKit owns the parsed route URL state used by matches, params, and href generation.
For the complete path guide, including detailed examples and custom constraints, see Path routes and constraints.
Built-in constraints are:
| Constraint | Syntax | Generated/runtime type | Purpose |
|---|---|---|---|
int | {id:int} | number | Unsigned integer segment. |
decimal | {price:decimal} | number | Finite decimal segment. |
range | {page:range(1,100)} | number | Numeric segment inside an inclusive range. |
min | {price:min(1)} | number | Numeric segment greater than or equal to the minimum. |
max | {price:max(10)} | number | Numeric segment less than or equal to the maximum. |
uuid | {id:uuid} | string | Canonical hyphenated UUID segment. |
minlength | {slug:minlength(3)} | string | Segment with at least the specified number of characters. |
maxlength | {slug:maxlength(50)} | string | Segment with no more than the specified number of characters. |
list | {view:list(grid|list|details)} | string | Segment that exactly matches one item from a pipe-separated list. |
regex | {slug:regex([a-z0-9-]+)} | string | Segment that matches a raw regex source. Do not include /.../ delimiters. |
Use {param} for an unconstrained string segment. There is no built-in {param:number} or {param:string} constraint.
Constraints can be chained:
/products/{price:decimal:min(1):max(10)}
/articles/{slug:minlength(3):maxlength(50)}
/scores/{score:regex(\d+):min(1)}URLKit infers parsed param types from the full constraint chain, not from the first constraint. If int, decimal, range, min, or max appears anywhere in the chain, Router runtime state and generated contracts use number. Otherwise the param uses string.
Index routes
Index routes inherit the parent path.
{
id: 'dashboard',
path: '/dashboard',
children: [
{
id: 'dashboard.overview',
index: true,
view: OverviewPage,
},
],
}Invalid:
{
id: 'dashboard.overview',
index: true,
path: '',
view: OverviewPage,
}Index routes must not define path.
Pathless layouts
A route may define a layout without a path.
{
id: 'app.layout',
layout: {
view: AppLayout,
},
children: [
{
id: 'account',
path: '/account',
view: AccountPage,
},
],
}The layout affects rendering but contributes no URL segment. Pathless routes are only valid as layout/group routes with children. A route that renders, redirects, declares search/hash contracts, or participates in navigation must define either path or index: true.
Params
Path params use PathKit path-pattern syntax and URLKit parsed-param semantics. PathKit validates the path pattern and constraint chain. URLKit parses route URL state with params: 'parsed', so runtime matches, useParams(), redirects, and generated contracts expose parsed values instead of raw URL strings.
{
id: 'organizations.users.show',
path: '/organizations/{organizationId:uuid}/users/{userId:int}',
view: OrganizationUserPage,
}Generated params and runtime match state use the same parsed URLKit values:
interface OrganizationUserParams {
organizationId: string;
userId: number;
}Param inference uses the full PathKit constraint chain:
| Pattern | Parsed/generated type | Runtime behavior |
|---|---|---|
{id} | string | Captures an unconstrained string segment. |
{id:int} | number | Parses an integer-shaped URL value. |
{price:decimal} | number | Parses a finite decimal URL value. |
{page:range(1,100)} | number | Parses a numeric value inside the inclusive range. |
{price:min(1)} | number | Parses a numeric value greater than or equal to the minimum. |
{price:max(10)} | number | Parses a numeric value less than or equal to the maximum. |
{id:uuid} | string | Matches canonical hyphenated UUID values. |
{slug:minlength(3)} | string | Matches values with at least the specified length. |
{slug:maxlength(50)} | string | Matches values with no more than the specified length. |
{view:list(grid|list|details)} | string | Matches one exact value from the list. |
{slug:regex([a-z0-9-]+)} | string | Matches the configured raw regex source. |
{score:regex(\d+):min(1)} | number | Numeric because min appears anywhere in the chain. |
{score:min(1):regex(\d+)} | number | Numeric for the same reason; constraint order does not change inference. |
{slug:slug} | string | Uses a registered custom constraint. |
{*path} | readonly string[] | Captures wildcard path data as decoded path segments in router state. |
Optional params generate optional properties and are absent when the segment is not present:
{
id: 'products.optional',
path: '/products/{id:int?}',
view: ProductPage,
}Generated params:
interface ProductParams {
id?: number;
};Custom path constraints let you define reusable validation rules for route params beyond the built-in int, decimal, range, min, max, uuid, minlength, maxlength, list, and regex constraints. Register them with pathConstraints before using them in route paths so the router can forward them to URLKit before route validation, matching, and href generation.
Custom constraints generate string params unless the same constraint chain also includes a numeric built-in constraint. For example, {slug:slug} is string, while {id:slug:min(1)} is number because min appears in the chain.
Duplicate param names in the same parent-to-child branch fail validation.
See Path routes and constraints for the complete constraint API.
Search, hash, and metadata
Search contracts are generated from URLKit-backed Router static search descriptors. Keep descriptors static in route files consumed by the CLI; do not use URLKit runtime builders there unless static extraction explicitly supports them.
{
id: 'articles.index',
path: '/articles',
search: {
query: { type: 'string', optional: true },
page: { type: 'int', default: 1 },
filters: { type: 'string', many: true, optional: true },
},
url: {
arrayFormat: 'comma',
unknownSearch: 'strip',
},
view: ArticlesPage,
}Generated fields and runtime state follow URLKit parsing semantics:
interface ArticlesSearch {
query?: string;
page: number;
filters?: readonly string[];
}url.arrayFormat controls repeated search param parsing and building. url.unknownSearch controls undeclared query keys and defaults to 'strip'. Router-level defaults can be set on createRouter({ url }); route-level url overrides router defaults; URL-building call-site options such as router.href(), router.navigate.to(), useHref(), Link, and NavLink can override build options such as arrayFormat and defaults.
When unknownSearch: 'preserve' is active, declared search remains typed and unknown keys are exposed separately on the match as unknownSearch.
Hash values become a string union:
{
id: 'articles.show',
path: '/articles/{slug}',
hash: { type: 'enum', values: ['comments', 'share'], optional: true },
view: ArticlePage,
}Metadata values are generated from the runtime typeof of the declared value and marked optional.
meta: {
title: 'Article',
requiresAuth: true,
}Generates a shape similar to:
{
title?: string;
requiresAuth?: boolean;
}Redirect routes
A route can redirect to another route without rendering a view. Redirect routes must be addressable with either path or index: true. Use an index redirect when a parent route should redirect from its own URL.
{
id: 'entry',
path: '/',
children: [
{
id: 'entry.redirect',
index: true,
redirect: {
route: 'dashboard', // "dashboard" represents the target route id
},
},
],
}Redirect with params, search, and hash:
{
id: 'legacy-user',
path: '/u/{id:int}',
redirect: {
route: 'users.show',
params: { id: 42 },
search: { tab: 'profile' },
hash: 'settings',
},
}Literal string redirects are also supported.
{
id: 'legacy-home',
path: '/home',
redirect: '/dashboard',
}Absolute string redirects leave the app in browser history.
{
id: 'external-docs',
path: '/docs',
redirect: 'https://docs.example.com',
}Use route-object redirects for internal targets when possible. They keep basenames, params, search, hash, and generated contract behavior consistent.
Layouts and outlets
A layout view wraps the active route child branch. It must render <Outlet /> to show children.
import { Outlet } from '@cookbook/router-react';
export function DashboardLayout() {
return (
<main>
<header>Dashboard</header>
<Outlet context={{ source: 'dashboard' }} />
</main>
);
}Direct route child views can read outlet context with useOutletContext().
const context = useOutletContext<{ source: string }>();Outlet context is direct-child scoped. It does not automatically leak through every descendant route.
Layout slots
Slots render named layout regions.
{
id: 'dashboard',
path: '/dashboard',
layout: {
view: DashboardLayout,
slots: {
sidebar: {
view: DashboardSidebar,
routes: [
{
id: 'dashboard.sidebar.activity',
path: 'activity',
view: ActivitySidebar,
},
],
},
modal: true,
},
},
}Render slots in the layout:
import { Outlet, Slot } from '@cookbook/router-react';
export function DashboardLayout() {
return (
<main>
<Outlet />
<Slot name="sidebar" context={{ user: 'Ada' }} />
<Slot name="modal" />
</main>
);
}Slot rules:
trueenables a declared slot without fallback content.- A slot
viewrenders when no slot route or intercept is active for that slot. - Slot configs support only
view,meta, androutes. - Slot names are layout-scoped, not global.
- Slot route IDs are generated because they are real URL-matched route definitions.
- React slots can isolate render errors with
<Slot errorFallback={...} />. - The removed
fallback,fallback.id, andidslot forms fail validation; see Route validation errors.
When a slot route shares a URL with primary content, define both routes:
children: [
{
id: 'dashboard.activity',
path: 'activity',
view: ActivityPage,
},
],
layout: {
slots: {
sidebar: {
routes: [
{
id: 'dashboard.sidebar.activity',
path: 'activity',
view: ActivitySidebar,
},
],
},
},
},Use the primary route for navigation and the slot route for the slot-specific UI.
Intercepting routes
Interception lets a source route preserve its current UI while rendering a destination route into a slot. The browser URL still updates to the canonical destination URL.
Configured intercepts are declared on the source route.
{
id: 'gallery',
path: '/gallery',
layout: {
view: GalleryLayout,
slots: {
modal: true,
},
},
intercepts: {
modal: {
to: 'gallery.photo',
view: PhotoModal,
},
},
children: [
{ id: 'gallery.index', index: true, view: GalleryPage },
],
}The canonical destination must exist as a normal route:
{
id: 'gallery.photo',
path: '/gallery/photo/{slug:regex([a-z0-9-]+)}',
view: PhotoPage,
}Link with configured interception:
<Link to="gallery.photo" params={{ slug }}>
Preview in modal
</Link>An inline interception is also supported. It does not require a route level intercepts declaration:
<Link
to="gallery.photo"
params={{ slug }}
intercept={{ slot: 'modal', view: PhotoModal }}
>
Preview in modal
</Link>Pass intercept={false} to bypass a configured interception for one navigation and render the destination as a normal full page:
<Link
to="gallery.photo"
params={{ slug }}
intercept={false}
>
Open full page
</Link>await router.navigate.replace('/gallery/photo/a-snowy-landscape', { intercept: false });Behavior:
- Client click from
/galleryto/gallery/photo/a-snowy-landscapecan renderPhotoModalin the active modal slot. - A navigation with
intercept: falseskips configured and call-site interception for that transition. - Direct entry to
/gallery/photo/a-snowy-landscaperendersPhotoPage. - Refresh on
/gallery/photo/a-snowy-landscapestill rendersPhotoModal. - Browser back closes the modal by returning to the previous URL.
- Browser forward can restore the modal during the same app session when intercept state exists in history.
Not found, loading, and error fallbacks
Provider fallback handles the simplest not-found case:
<RouterProvider router={router} fallback={<NotFoundPage />} />Use RouterProvider fallback for global 404 UI. For section-specific 404 UI, define an explicit catch-all child route inside that section so the section layout stays active.
{
id: 'admin',
path: '/admin',
layout: { view: AdminLayout },
children: [
{
id: 'admin.not-found',
path: '{*path}',
view: AdminNotFound,
},
],
}Route-level loading views are used by @cookbook/router-react as React Suspense fallbacks. They render while a lazy route view, layout, slot route, or intercepted route suspends.
Route-level error views are used by @cookbook/router-react as React error-boundary fallbacks. The nearest matched route with an error view owns errors thrown by its route subtree. The fallback receives error, reset, and route props.
function ArticleLoading() {
return <ArticleSkeleton />;
}
function ArticleErrorFallback(props: RouteErrorFallbackProps) {
return (
<section role="alert">
<h1>Article failed to render</h1>
<button type="button" onClick={props.reset}>
Try again
</button>
</section>
);
}
{
id: 'blog.articles.show',
path: 'articles/{slug}',
view: ArticlePage,
loading: ArticleLoading,
error: ArticleErrorFallback,
}error handles React rendering and lazy-import errors. Router transition errors from middleware or lifecycle hooks still flow through router navigation error handling.
Middleware and lifecycle on routes
Routes can own middleware and lifecycle hooks.
{
id: 'admin',
path: '/admin',
view: AdminPage,
meta: { requiresAuth: true },
middleware: [requireAuth],
lifecycle: {
beforeEnter: ({ location }) => {
analytics.preview(location.href);
},
afterEnter: ({ location }) => {
analytics.page(location.href);
},
},
}Global middleware and lifecycle hooks are configured on the router. Route-level middleware and lifecycle hooks are resolved through the matched branch.
Router configuration
const router = createRouter({
routes,
basename: '/app',
maxRedirectDepth: 10,
pathOptions: {
prune: 'all',
},
});basename
basename is a visible URL prefix.
createRouter({ routes, basename: '/foo' });router.href('blog.index')includes/foo.- Matching strips
/foobefore route matching. - Browser URLs keep
/foovisible. - Intercept matching compares app paths after stripping the basename.
maxRedirectDepth
Redirects are bounded to prevent loops. Use maxRedirectDepth; maxRedirectionDepth is accepted as an alias.
createRouter({ routes, maxRedirectDepth: 20 });pathConstraints
Custom path constraints let route params use reusable validation rules beyond the built-in decimal, int, uuid, min, max, range, minlength, maxlength, list, and regex constraints. Create custom constraints with createPathConstraint() and register them through defineRoutes(..., { pathConstraints }) before using them in route paths.
defineRoutes() validates route patterns immediately, so any custom constraint referenced by a route path must already be registered. Cookbook Router forwards registered constraints to URLKit before descriptor validation, matching, parsing, and href building. For all built-in constraints, custom constraint APIs, and common mistakes, see Path routes and constraints.
import { createPathConstraint, createRouter, defineRoutes } from '@cookbook/router';
const slug = createPathConstraint({
parse: (paramName, value) => {
if (typeof value !== 'string' || !/^[a-z0-9-]+$/.test(value)) {
throw new Error(`Parameter "${paramName}" must be a valid slug.`);
}
},
verify: (_paramName, params) => {
if (params) {
throw new Error('slug does not accept parameters.');
}
},
toRegExp: () => '[a-z0-9-]+',
});
const routes = defineRoutes([{ id: 'posts.show', path: '/posts/{slug:slug}' }] as const, {
pathConstraints: { slug },
});
const router = createRouter({
routes,
});defineRoutes(..., { pathConstraints }) registers constraints before immediate route validation. Router creation also accepts pathConstraints for route arrays that have not already been validated. Register the same constraints on the server and client when using SSR.
pathOptions.prune
Path options are forwarded to PathKit wrappers and router canonicalization. URL state parsing/building remains owned by URLKit.
Default:
pathOptions: {
prune: 'all',
}Supported values:
| Value | Behavior |
|---|---|
'all' | Remove duplicated delimiters and trailing delimiters. |
'duplication' | Remove duplicated delimiters only. |
'trailing' | Remove trailing delimiters only. |
false | Preserve paths exactly as declared/generated. |
With the default, /gallery/ canonicalizes to /gallery when it matches a route.
Matching and ranking
Matching is deterministic:
- Static routes rank before dynamic routes.
- Dynamic routes rank before wildcard routes.
- Index routes are prioritized for their parent path.
- Route IDs remain the primary lookup key for navigation and diagnostics.
Matching uses normalized route paths and URLKit-backed route URL contracts. PathKit remains the lower-level path-pattern primitive beneath URLKit. Catch-all wildcard routes are always ranked below concrete static and dynamic routes, so a root /{*path} not-found route cannot outrank /overview or /users/{id}.
Validation diagnostics
defineRoutes(), validateRoutes(), router creation, and CLI generation validate route trees before they are used. Error messages include route IDs or invalid field names where possible.
For the full catalog of route validation failures with symptoms, causes, and fixes, see Route validation errors.
Best practices
- Treat route IDs as stable public API.
- Prefer route-object redirects over literal internal string redirects.
- Use
basenameinstead of hard-coding deployment prefixes in route paths. - Define primary routes for navigable pages and slot routes for slot-specific UI.
- Use direct
useOutletContext<Context>()for slot view context unless you have generated outlet context contracts. - Use configured intercepts for route-owned UX patterns and call-site intercepts for local UI decisions.
- Keep external URLs out of route params; use string redirects or normal anchors for external navigation.