Route metadata
Attach application-owned information to routes, read local or ancestor metadata, and merge it with explicit policies.
Route metadata attaches application-owned information to the route tree.
Use it for declarative information such as:
- Page titles
- Breadcrumbs
- Navigation labels
- Access hints
- Analytics names
- Layout preferences
- SEO configuration
- Feature flags
Cookbook Router preserves metadata on normalized and matched routes. It does not use metadata to decide whether a route matches or how navigation behaves.
Your application decides what each metadata key means.
Core and React reads
Metadata is stored in the core route tree. React hooks read the same data through render context.
import {
getActiveRouteMetaChain,
mergeRouteMetaChain,
} from '@cookbook/router';
const chain = getActiveRouteMetaChain(
router.state.match,
{
includeAncestors: true,
},
);
const meta = mergeRouteMetaChain(chain, {
default: 'shallow',
});Use core helpers in head managers, analytics pipelines, server renderers, and non-React adapters.
import { useRouteMeta } from '@cookbook/router-react';
function PageTitle() {
const meta = useRouteMeta({
includeAncestors: true,
merge: {
default: 'shallow',
},
});
return <title>{meta.title}</title>;
}React hooks use the route render context and can read local, active, or target-route metadata.
Declare metadata
Add a meta object to a route:
import {
defineRoutes,
} from '@cookbook/router';
export const routes = defineRoutes([
{
id: 'root',
path: '/',
meta: {
title: 'Application',
chrome: {
sidebar: true,
},
},
children: [
{
id: 'users',
path: 'users',
meta: {
title: 'Users',
breadcrumb: {
label: 'Users',
route: 'users',
},
},
},
],
},
] as const);The authored metadata type is:
type RouteMeta = Record<string, unknown>;The router requires meta to be an object. Its values are application-owned.
Metadata does not automatically:
- Change route ranking
- Enforce authentication
- Set the document title
- Create breadcrumbs
- Merge with parent metadata
- Affect generated hrefs
- Become component state
Those behaviors only exist when application code reads and interprets the metadata.
Metadata is route-local
Metadata metadata
- Affect generated hrefs
- Become component state
Those behaviors only belongs to the route where it is declared.
{
id: 'root',
path: '/',
meta: {
title: 'Application',
},
children: [
{
id: 'users',
path: 'users',
meta: {
section: 'accounts',
},
},
],
}The users route metadata is:
{
section: 'accounts',
}It does not automatically become:
{
title: 'Application',
section: 'accounts',
}Ancestor collection and merging are explicit read operations.
This keeps route declarations predictable. A route’s normalized meta field always represents its own authored metadata.
Read one route
Use getRouteMeta() to read local metadata for a route ID:
import { getRouteMeta } from '@cookbook/router';
const meta = getRouteMeta(
router.routes,
'users',
);Given:
{
id: 'users',
path: '/users',
meta: {
title: 'Users',
access: 'private',
},
}the result is:
{
title: 'Users',
access: 'private',
}getRouteMeta() does not include ancestors.
It can read an inactive route because it works from the normalized route tree rather than the current match.
An unknown route ID returns:
{}Read a target route chain
Use getRouteMetaChain() when metadata from a specific route and its ancestors is needed:
import { getRouteMetaChain } from '@cookbook/router';
const chain = getRouteMetaChain(
router.routes,
'users.details',
{
includeAncestors: true,
},
);The chain is ordered from root to target:
root
users
users.detailsWithout includeAncestors: true, only the target route is returned:
const chain = getRouteMetaChain(
router.routes,
'users.details',
);users.detailsThe target route does not need to be active.
An unknown route ID returns:
[]Read the active metadata chain
Use getActiveRouteMetaChain() when metadata must come from the current match:
import { getActiveRouteMetaChain } from '@cookbook/router';
const chain = getActiveRouteMetaChain(
router.state.match,
{
includeAncestors: true,
},
);For an active location such as:
/users/42the chain can contain:
root
users
users.detailsWithout includeAncestors: true, only the active leaf entry is returned.
const chain = getActiveRouteMetaChain(
router.state.match,
);When there is no active match, the result is:
[]Target chain versus active chain
The two chain helpers serve different purposes.
| Helper | Source | Requires active match | Parsed params |
|---|---|---|---|
getRouteMetaChain() | Normalized route tree | No | No |
getActiveRouteMetaChain() | Current RouteMatch | Yes | Yes |
Use getRouteMetaChain() for:
- Menus
- Static navigation models
- Route inspection
- Metadata for an inactive destination
- Build or application tooling
Use getActiveRouteMetaChain() for:
- Current breadcrumbs
- Active page chrome
- Metadata that needs parsed route params
- Runtime analytics for the active branch
Metadata chain entries
Both helpers return RouteMetaEntry objects:
interface RouteMetaEntry<
Route extends string = RouteId,
> {
readonly id: Route;
readonly params: Record<string, unknown>;
readonly meta: Record<string, unknown>;
readonly route: NormalizedRoute;
readonly match?: MatchedRoute;
}Each entry contains:
id: the route IDmeta: local metadata declared by that routeroute: the normalized routeparams: params available for that entrymatch: the active matched entry, when the chain came from an active match
For a static target chain, params is {} and match is absent.
For an active chain, params are the parsed values visible at that branch level:
const chain = getActiveRouteMetaChain(
router.state.match,
{
includeAncestors: true,
},
);
for (const entry of chain) {
console.log(
entry.id,
entry.params,
entry.meta,
);
}This is useful when breadcrumb labels depend on parsed parameters.
Merge a metadata chain
Use mergeRouteMetaChain() to turn an ordered chain into one metadata object:
import { mergeRouteMetaChain } from '@cookbook/router';
const chain = getActiveRouteMetaChain(
router.state.match,
{
includeAncestors: true,
},
);
const meta = mergeRouteMetaChain(chain);The chain is processed from root to leaf.
The default merge mode is:
'shallow'Merge modes
Cookbook Router supports five merge modes:
| Mode | Behavior |
|---|---|
'leaf' | The later route value replaces the earlier value for the same key |
'shallow' | Plain objects at the same key are merged one level; other values use the later value |
'deep' | Plain objects are recursively merged; other values use the later value |
'append' | Values are collected into an array from root to leaf |
'prepend' | Values are collected into an array from leaf to root |
leaf
leaf selects the leaf-most defined value per metadata key:
const meta = mergeRouteMetaChain(
chain,
'leaf',
);Given:
// root
{
headerHeight: 16,
title: 'Application',
}
// child
{
title: 'Users',
}the result is:
{
headerHeight: 16,
title: 'Users',
}leaf does not discard the complete ancestor object. Ancestor-only keys remain.
It means:
For each duplicated key, keep the value nearest the leaf.
shallow
shallow is the default.
When both values for a key are plain objects, they are merged one level:
// root
{
chrome: {
sidebar: true,
density: 'comfortable',
},
}
// child
{
chrome: {
density: 'compact',
},
}Result:
{
chrome: {
sidebar: true,
density: 'compact',
},
}For strings, numbers, arrays, dates, class instances, and other non-plain objects, the later value replaces the earlier one.
deep
deep recursively merges plain objects:
// root
{
seo: {
robots: {
index: true,
follow: true,
},
openGraph: {
siteName: 'Cookbook',
},
},
}
// child
{
seo: {
robots: {
index: false,
},
openGraph: {
section: 'users',
},
},
}Result:
{
seo: {
robots: {
index: false,
follow: true,
},
openGraph: {
siteName: 'Cookbook',
section: 'users',
},
},
}Arrays are not deep-merged. A child array replaces an ancestor array.
append
append collects values from root to leaf:
const meta = mergeRouteMetaChain(
chain,
{
keys: {
breadcrumb: 'append',
},
},
);Given:
// root
{
breadcrumb: {
label: 'Home',
route: 'root',
},
}
// child
{
breadcrumb: {
label: 'Users',
route: 'users',
},
}
// leaf
{
breadcrumb: {
label: 'Details',
},
}the result contains:
{
breadcrumb: [
{
label: 'Home',
route: 'root',
},
{
label: 'Users',
route: 'users',
},
{
label: 'Details',
},
],
}If a value is already an array, its elements are concatenated into the result.
prepend
prepend collects values in the opposite direction:
const meta = mergeRouteMetaChain(
chain,
{
keys: {
breadcrumb: 'prepend',
},
},
);Result:
{
breadcrumb: [
{
label: 'Details',
},
{
label: 'Users',
route: 'users',
},
{
label: 'Home',
route: 'root',
},
],
}Configure merge behavior per key
Different metadata keys often need different policies.
const chain = getActiveRouteMetaChain(
router.state.match,
{
includeAncestors: true,
},
);
const meta = mergeRouteMetaChain(
chain,
{
default: 'leaf',
keys: {
breadcrumb: 'append',
chrome: 'deep',
seo: 'deep',
},
},
);Given metadata across the branch:
// root
{
breadcrumb: {
label: 'Home',
route: 'root',
},
chrome: {
sidebar: true,
density: 'comfortable',
},
seo: {
robots: {
index: true,
follow: true,
},
},
}
// users
{
breadcrumb: {
label: 'Users',
route: 'users',
},
chrome: {
density: 'compact',
},
}
// users.details
{
breadcrumb: {
label: 'Details',
},
title: 'User details',
seo: {
robots: {
index: false,
},
},
}the merged result is:
{
breadcrumb: [
{
label: 'Home',
route: 'root',
},
{
label: 'Users',
route: 'users',
},
{
label: 'Details',
},
],
chrome: {
sidebar: true,
density: 'compact',
},
title: 'User details',
seo: {
robots: {
index: false,
follow: true,
},
},
}Per-key rules override the default mode.
Undefined and null values
undefined metadata values are skipped:
// parent
{
title: 'Users',
}
// child
{
title: undefined,
}Result:
{
title: 'Users',
}null is preserved as an explicit value:
// parent
{
title: 'Users',
}
// child
{
title: null,
}Result:
{
title: null,
}Use null when a child should explicitly clear an inherited value.
React useRouteMeta()
The React integration exposes the same metadata model through useRouteMeta():
import { useRouteMeta } from '@cookbook/router-react';Read local rendered-route metadata
Call the hook without arguments:
function UserPage() {
const meta = useRouteMeta();
return null;
}When a local route render context exists, the hook returns metadata for that rendered route.
This matters in layouts, route views, slot routes, and intercepted views: the nearest render context is preferred over the global active leaf.
Outside a local route render context, the hook falls back to the active leaf route.
Read a specific route
Pass a route ID:
const meta = useRouteMeta('users');This reads the target route even when it is not currently active.
An unknown route ID returns:
{}Include the active branch
Without a route ID, includeAncestors: true reads the complete active branch from root to active leaf:
const meta = useRouteMeta({
includeAncestors: true,
});The default merge mode remains shallow.
This uses the active router branch even when the hook is called from a parent layout’s local render context.
Include ancestors of a target route
Pass a route ID when the chain should stop at a particular route:
const meta = useRouteMeta(
'users.details',
{
includeAncestors: true,
},
);The chain is:
root
users
users.detailsIf the route is active, its active branch entries are used.
If it is inactive, the normalized route tree is used.
Configure React merging
The hook accepts the same merge input as mergeRouteMetaChain():
const meta = useRouteMeta({
includeAncestors: true,
merge: {
default: 'leaf',
keys: {
breadcrumb: 'append',
chrome: 'deep',
seo: 'deep',
},
},
});A string merge mode is also accepted:
const meta = useRouteMeta({
includeAncestors: true,
merge: 'leaf',
});Read unmerged metadata objects
Pass merge: false to receive the ordered metadata objects:
const chain = useRouteMeta({
includeAncestors: true,
merge: false,
});Result:
[
{
title: 'Application',
},
{
title: 'Users',
},
{
title: 'User details',
},
]This returns metadata objects only.
It does not return RouteMetaEntry values, route IDs, params, or normalized routes.
Use getActiveRouteMetaChain() when those details are needed.
For an unknown route ID:
useRouteMeta(
'missing',
{
merge: false,
},
);the result is:
[]Breadcrumbs from active metadata
Use an unmerged active chain when breadcrumb labels need route IDs or params:
const chain = getActiveRouteMetaChain(
router.state.match,
{
includeAncestors: true,
},
);
const breadcrumbs = chain.flatMap(
(entry) => {
const breadcrumb =
entry.meta.breadcrumb;
if (
!breadcrumb ||
typeof breadcrumb !== 'object'
) {
return [];
}
return [
{
routeId: entry.id,
params: entry.params,
breadcrumb,
},
];
},
);Use append when each route already declares a complete breadcrumb value and only the ordered values are required:
const merged = mergeRouteMetaChain(
chain,
{
keys: {
breadcrumb: 'append',
},
},
);Metadata does not enforce policy
Metadata can describe policy:
{
id: 'account',
path: '/account',
meta: {
requiresAuth: true,
},
}It does not enforce it.
Middleware, lifecycle, rendering, or application code must interpret the value:
const requireAuth: Middleware = ({
route,
redirect,
}) => {
if (
route.route.meta?.requiresAuth === true &&
!session.isAuthenticated()
) {
return redirect('/login');
}
};The middleware context exposes the leaf route’s local metadata. Ancestor metadata is not automatically merged into it.
Attach branch-wide middleware to the relevant parent route or perform explicit metadata-chain processing when inherited policy is intentional.
Generated metadata contracts
The CLI generates route-local metadata contracts in:
.cookbook-router/contracts.tsGiven:
meta: {
access: 'private',
requiresAuth: true,
priority: 10,
seo: {
index: false,
},
}generation produces a route entry resembling:
{
access?: string;
requiresAuth?: boolean;
priority?: number;
seo?: object;
}Generated metadata typing is intentionally shallow:
- Every property is optional.
- String literals become
string. - Number literals become
number. - Boolean literals become
boolean. - Arrays, plain objects, and
nullbecomeobject. - Nested object structure is not generated.
- Ancestor metadata is not added to the child route contract.
Application code reads one registered route contract with:
import type { RegisteredRouteMeta } from '@cookbook/router';
type LoginMeta = RegisteredRouteMeta<'login'>;The authored metadata object type exported for route configuration is RouteMeta.
The generated route selector is exported as RegisteredRouteMeta to distinguish the two.
Merged metadata and generated types
Ancestor merging happens at runtime.
The generated metadata contract remains local to each route.
For example:
// root metadata
{
headerHeight: 16,
}
// users.details metadata
{
title: 'User details',
}This call returns both values at runtime:
const meta = useRouteMeta(
'users.details',
{
includeAncestors: true,
merge: 'leaf',
},
);Runtime result:
{
headerHeight: 16,
title: 'User details',
}However, the registered users.details metadata type only describes its local generated keys. It does not automatically add headerHeight.
When consuming a dynamically merged chain, use the core helper’s Record<string, unknown> result and narrow application-owned values:
const chain = getRouteMetaChain(
router.routes,
'users.details',
{
includeAncestors: true,
},
);
const meta = mergeRouteMetaChain(
chain,
'leaf',
);
const headerHeight =
typeof meta.headerHeight === 'number'
? meta.headerHeight
: 12;For a consistent application metadata model, define an application-owned interface and use satisfies at declaration sites:
interface AppRouteMeta {
readonly title?: string;
readonly headerHeight?: number;
readonly access?: 'public' | 'private';
readonly breadcrumb?: {
readonly label: string;
readonly route?: string;
};
readonly chrome?: {
readonly sidebar?: boolean;
};
}meta: {
title: 'Users',
access: 'private',
} satisfies AppRouteMetaCookbook Router does not impose one global metadata schema.
Static generation boundary
For manually authored runtime route trees, metadata values may contain arbitrary application values because the runtime contract is:
Record<string, unknown>The router only verifies that meta is an object and rejects unsafe top-level property names.
When the CLI must statically inspect route files, metadata must remain statically extractable.
Supported metadata should be composed from data values such as:
- Strings
- Numbers
- Booleans
nullundefined- Arrays
- Plain objects
- Supported local or relative imported static constants
Avoid runtime function calls or computed application state in CLI-consumed metadata:
// Not statically extractable
meta: createMetadata();Prefer:
const userMetadata = {
title: 'Users',
access: 'private',
} as const;
export const usersRoute = defineRoute({
id: 'users',
path: '/users',
meta: userMetadata,
});Codegen-relevant imported metadata must follow the CLI static import rules.
See Typed contracts for generated metadata typing and the CLI generation reference for extraction details.
Choose the correct read API
| Need | API |
|---|---|
| Local metadata for one route ID | getRouteMeta() |
| Static target route plus optional ancestors | getRouteMetaChain() |
| Current matched route plus optional ancestors and params | getActiveRouteMetaChain() |
| Merge an existing chain | mergeRouteMetaChain() |
| Read metadata inside React | useRouteMeta() |
| Read unmerged React metadata objects | useRouteMeta({ merge: false }) |
Where this bites
Parent metadata is missing from a child
Metadata is local. Request includeAncestors: true and choose a merge strategy.
leaf removes too little
leaf selects the leaf-most value per duplicated key. Ancestor-only keys remain.
Arrays disappear during deep merge
deep only recursively merges plain objects. Arrays are replaced. Use append or prepend when arrays should be collected.
undefined does not clear an ancestor value
Undefined entries are skipped. Use null when an explicit cleared value is required.
useRouteMeta() returns parent metadata in a layout
Without a route ID, the hook prefers the nearest local route render context. Pass a route ID when another route is intended.
includeAncestors reads through the active leaf
Without a route ID, includeAncestors: true uses the complete active match branch. Pass a target route ID when the chain should stop earlier.
merge: false has no route IDs
The React hook returns metadata objects only. Use a core chain helper for IDs, params, normalized routes, and active match entries.
Merged ancestor keys are missing from TypeScript
Generated metadata contracts describe local route metadata. Runtime ancestor merging can add keys that are not present in the selected route’s generated type.
Metadata does not protect a route
Metadata only describes application intent. Middleware or other application logic must enforce it.
Metadata generation loses nested types
The current generator uses top-level JavaScript typeof categories. Nested objects are represented as object, not deeply generated interfaces.