Navigation
Href generation, resolution, matching, transitions, blockers, redirects, rewrites, and history movement.
Cookbook Router navigation is route-ID based. Paths declare URL matching; route IDs drive href generation, links, programmatic navigation, redirects, and generated type inference. URLKit builds and parses params, search, and hash for these route operations.
Core and React entry points
The core router owns href generation and transitions. React links and hooks delegate to the same router instance.
const href = router.href('users.show', {
params: {
id: 42,
},
search: {
tab: 'settings',
},
});
await router.navigate.to('users.show', {
params: {
id: 42,
},
});Use core APIs in non-React renderers, command handlers, tests, services, and framework adapters.
import { Link } from '@cookbook/router-react';
<Link
to="users.show"
params={{
id: 42,
}}
search={{
tab: 'settings',
}}
>
User 42
</Link>React links render real anchors and intercept only unmodified same-origin clicks.
Href generation
Use router.href() to generate a URL from a route ID.
const href = router.href({
route: 'users.show',
params: { id: 42 },
search: { tab: 'settings' },
hash: 'profile',
});Generated URL:
/users/42?tab=settings#profile{id:int}, {price:decimal}, {value:range(1,10)}, {value:min(1)}, and {value:max(10)} params use numbers in generated contracts and router state. uuid, minlength, maxlength, list, regex, unconstrained params, and custom constraints remain strings unless the same constraint chain also includes a numeric built-in constraint. Wildcards are parsed as readonly string[] path segments. See Path routes and constraints for all built-in constraints.
The two-argument form is also supported:
const href = router.href('users.show', {
params: { id: 42 },
});Use router.resolve() when you need a parsed RouterLocation.
const location = router.resolve({
route: 'users.show',
params: { id: 42 },
});
location.pathname; // /users/42
location.href; // /users/42Programmatic navigation
await router.navigate.to({
route: 'users.show',
params: { id: 42 },
});
await router.navigate.replace({
route: 'users.show',
params: { id: 43 },
});
router.navigate.back();
router.navigate.forward();
router.navigate.go(-2);to()pushes a history entry.replace()replaces the current entry.back(),forward(), andgo(delta)delegate to the configured history.
React links
Link renders a real <a> element.
import { Link } from '@cookbook/router-react';
<Link to="users.show" params={{ id: 42 }} search={{ tab: 'settings' }} hash="profile">
User 42
</Link>;route and to are aliases. Prefer to in React code because it reads like a link target.
Use replace for replace navigation:
<Link to="settings" replace>
Settings
</Link>Use href for literal anchors that are not route-driven:
<Link href="https://example.com/docs" target="_blank" rel="noreferrer">
External docs
</Link>Route preload and link prefetch
Use router.preload() to warm a route without committing navigation, writing history, running middleware, or running navigation lifecycle hooks.
await router.preload('users.show', {
params: { id: 42 },
});
await router.preloadHref('/users/42');React links can prefetch their route target on explicit triggers. Prefetch is disabled by default.
<Link to="users.show" params={{ id: 42 }} prefetch="interaction">
User 42
</Link>Supported prefetch modes are:
| Mode | Trigger |
|---|---|
false | Never. This is the default. |
"hover" | Pointer enters the link. |
"focus" | Link receives focus. |
"interaction" | Hover or focus. |
"mount" | Link mounts. |
For static/manual lazy views, use lazyRouteView so route preload can warm the lazy import without an explicit route-level preload.
const UsersPage = lazyRouteView(() => import('./users-page'));
defineRoute({
id: 'users.index',
path: '/users',
view: UsersPage,
});Route-level preload is optional and should be used for application-owned warming such as query caches, images, permissions, or configuration. Generated/file-based route modules and lazyRouteView can be preloaded without a declared route-level preload callback.
defineRoute({
id: 'users.show',
path: '/users/{id:int}',
preload: async ({ params, signal }) => {
await queryClient.prefetchQuery({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id, { signal }),
});
},
});Route preload is not data loading
Route preload is not a loader. It does not store data in the router, block rendering, serialize SSR data, or trigger revalidation. Use it to warm application-owned systems such as TanStack Query, SWR, Relay, Apollo, image caches, or configuration caches.
Loaders, actions, mutations, and revalidation are intentionally outside the router data model for now.
Active links
NavLink computes active state from the current location.
import { NavLink } from '@cookbook/router-react';
<NavLink to="blog.articles" end>
{({ isActive }) => <span data-active={isActive}>Articles</span>}
</NavLink>;By default, NavLink uses prefix matching. A link is active when the current
URL matches the generated href, or when the current pathname starts with the
target pathname.
Use end when the link should only be active for an exact URL match:
<NavLink to="blog.articles" end>
Articles
</NavLink>With end, the pathname, search string, and hash must all match.
Use end={{ search: 'ignore' }} when search params should not affect active
state:
<NavLink to="products" search={{ page: 1 }} end={{ search: 'ignore' }}>
Products
</NavLink>With end={{ search: 'ignore' }}, the pathname and hash must match, but the
search string is ignored.
end={{ search: 'all' }} is equivalent to end: pathname, search string, and
hash must all match.
Active links receive aria-current="page".
Search and hash
Search fields are serialized into the query string.
router.href({
route: 'blog.articles',
search: { query: 'routing' },
});Hash values may be passed with or without #.
router.href({ route: 'articles.show', params: { slug }, hash: 'comments' });
router.href({ route: 'articles.show', params: { slug }, hash: '#comments' });Both produce #comments.
undefined and null search values are omitted from generated URLs. Search and hash are parsed through URLKit in router.match(), router.resolve(), middleware contexts, lifecycle contexts, and React hooks.
URL options
URL options can be configured globally on the router, per route, or on URL-building call sites. Route-resolution options include arrayFormat, invalidSearch, invalidHash, and unknownSearch. URL-building APIs such as router.href(), router.navigate.to(), useHref(), Link, and NavLink accept build options such as arrayFormat and defaults.
const router = createRouter({
routes,
url: { arrayFormat: 'repeat' },
});
const href = router.href('products', {
search: { tags: ['router', 'typescript'] },
url: { arrayFormat: 'comma' },
});For URL building, precedence is call-site url, then route-level url, then router-level url, then URLKit defaults. repeat writes ?tags=router&tags=typescript; comma writes ?tags=router%2Ctypescript.
invalidSearch and invalidHash support 'recover', 'no-match', and 'error'. The default is 'recover': URLKit omits invalid optional/defaulted values when possible, descriptor defaults apply when declared, and required invalid values still surface as errors. 'no-match' rejects the route candidate and continues fallback/not-found matching. 'error' keeps the path route matched and exposes the parse failure through router error state. unknownSearch supports 'strip', 'preserve', and 'error'; its default is 'strip'. Use 'preserve' when undeclared query keys should remain available as unknownSearch on the match.
Redirects
Route redirects are declared in route config.
{
id: 'entry',
path: '/',
redirect: {
route: 'blog.index',
},
}Middleware redirects are returned from middleware.
const requireAuth = ({ route, location, redirect }) => {
if (route.route.meta?.requiresAuth) {
return redirect(`/blog/login?redirect=${encodeURIComponent(location.href)}`);
}
};String redirects are literal hrefs. Absolute hrefs are treated as external browser redirects:
{
id: 'external-docs',
path: '/docs',
redirect: 'https://docs.example.com',
}Use route-object redirects for app routes when possible because they compose with basename, params, search, and hash.
Basename
A basename prefixes generated hrefs and visible browser URLs.
const router = createRouter({
routes,
basename: '/cookbook',
});router.href({ route: 'blog.index' }); // /cookbook/blog
router.match('/cookbook/blog'); // matches blog.indexConfigured intercepts compare against app paths after basename stripping, so route config should not include the basename.
router.match() can also validate full hrefs and preserve parsed search/hash values:
const matchedRedirect = router.match('/users/eddie-lake?tab=activity#top');
if (matchedRedirect) {
await router.navigate.replace(matchedRedirect.id, {
params: matchedRedirect.params,
search: matchedRedirect.search,
hash: matchedRedirect.hash,
});
}Interception
Configured interception:
<Link
to="blog.articles.show"
params={{ slug }}
intercept="modal"
>
Read in modal
</Link>Inline interception:
<Link
to="blog.articles.show"
params={{ slug }}
intercept={{ slot: 'modal', view: ArticleModal }}
>
Preview
</Link>Disable configured interception for one navigation when the destination should render as its canonical page, such as an auth redirect:
await router.navigate.replace(
'/blog/articles/hello-world',
{ intercept: false }
);
await router.navigate.to(
'blog.articles.show',
{
params: { slug },
intercept: false
}
);<Link
to="blog.articles.show"
params={{ slug }}
intercept={false}
>
Open full page
</Link>Call-site intercept state stored in browser history is clone-safe. The view reference is held in memory for the current app session, so forward navigation can restore it during the same runtime session. A refresh or direct visit renders the canonical page.
Browser behavior
Link preserves normal browser behavior for:
- already prevented events
- non-left clicks
meta,ctrl,alt, orshiftclickstargetvalues other than_selfdownloadlinks- external
httporhttpslinks mailto:andtel:links
Unmodified same-origin route clicks are intercepted and routed through router.navigate.
Navigation state
Use useNavigation() to read transition state.
const navigation = useNavigation();
return navigation === 'pending' ? <Spinner /> : null;The router navigation states are:
idle
pending
redirecting
blocked
errorCommon edge cases
- Missing params throw during href generation.
- Unknown route IDs throw during href generation and navigation.
- Invalid constrained params fail compilation or matching.
- Middleware returning
falseorcancel()blocks navigation without committing the URL. - Redirect loops fail after
maxRedirectDepthredirects. - Browser history state must not contain functions. Use route IDs or call-site intercepts, not custom function values in history state.
- If examples keep using old behavior after package source changes, rebuild packages with
pnpm build:packages.