Intercepting routes
Render a canonical destination inside a source-owned slot while preserving the source branch.
Intercepting routes let client navigation display a destination inside a named slot while keeping the source page rendered behind it.
This is useful for:
- Modal details
- Quick previews
- Drawers
- Inspectors
- Compose views
- Contextual editors
The destination remains a normal canonical route. Interception only changes how that navigation is rendered.
Core state and React rendering
Interception has two layers: core state and adapter rendering.
The core router records the destination match and the intercepted rendering state.
const state = await router.navigate.to({
route: 'photos.show',
params: {
photoId: 42,
},
intercept: 'modal',
});
state.match?.id;
// 'photos.show'
state.match?.intercepted?.slot;
// 'modal'
state.match?.intercepted?.previousHref;
// '/gallery'A custom renderer can inspect match.intercepted and match.slots to decide how to draw the source branch and intercepted target.
React renders the intercepted target through a source-owned <Slot />.
import { Link } from '@cookbook/router-react';
<Link
to="photos.show"
params={{
photoId: 42,
}}
intercept="modal"
>
Open photo
</Link>The URL becomes /photos/42, but the source page can remain visible behind the modal slot.
The interception model
Suppose the user is viewing:
/galleryand selects a photo whose canonical route is:
/photos/42With interception:
| State | Result |
|---|---|
| Address bar | /photos/42 |
| Router location | /photos/42 |
| Router match | photos.show |
| Primary rendered branch | The previous /gallery branch |
| Named slot | The photo intercept view |
| Previous location | /gallery |
The router performs a real navigation to the destination. The React provider then reconstructs the previous source branch and renders the destination through one of that source branch’s slots.
Interception is therefore not a rewrite:
- A rewrite suppresses the history write.
- An intercept commits the destination URL and changes how it is rendered.
Complete example
Declare a modal slot in the source layout:
import {
Outlet,
Slot,
} from '@cookbook/router-react';
export function GalleryLayout() {
return (
<section>
<header>
<h1>Gallery</h1>
</header>
<main>
<Outlet />
</main>
<Slot
name="modal"
errorFallback={PhotoModalError}
/>
</section>
);
}Configure the source route to intercept the photo destination:
import { defineRoutes } from '@cookbook/router';
export const routes = defineRoutes([
{
id: 'gallery',
path: '/gallery',
layout: {
view: GalleryLayout,
slots: {
modal: true,
},
},
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
},
children: [
{
id: 'gallery.index',
index: true,
view: GalleryPage,
},
],
},
{
id: 'photos.show',
path: '/photos/{photoId:int}',
view: PhotoPage,
},
] as const);Navigate normally from the gallery:
import { Link } from '@cookbook/router-react';
export function GalleryPage() {
return (
<Link
route="photos.show"
params={{
photoId: 42,
}}
>
Open photo
</Link>
);
}The configured intercept is automatic. The link does not need an intercept prop.
Client navigation from /gallery to /photos/42 renders PhotoModal in the gallery’s modal slot.
A direct visit to /photos/42 renders the canonical PhotoPage.
Source, destination, and intercept view
Interception involves three separate pieces.
Source route
The route branch currently rendered before navigation:
gallery
gallery.indexThe source branch owns the slot and declares which destinations it can intercept.
Destination route
The canonical route selected by the target URL:
photos.showThe destination still owns:
- URL matching
- Path params
- Search state
- Hash state
- Metadata
- Middleware
- Lifecycle
- Canonical page rendering
Intercept view
The alternate view rendered inside the source slot:
function PhotoModal() {
const params = useParams('photos.show');
return (
<Dialog>
Photo {params.photoId}
</Dialog>
);
}The intercept view receives the destination route render context. Route hooks therefore read destination state.
Render the canonical destination inside the intercept
The intercept view receives the rendered destination branch as its outlet.
Use <Outlet /> when the modal or drawer should wrap the canonical destination UI:
import {
Outlet,
useRouter,
} from '@cookbook/router-react';
export function PhotoModal() {
const router = useRouter();
return (
<Dialog
onDismiss={() => {
router.navigate.back();
}}
>
<Outlet />
</Dialog>
);
}In this example, <Outlet /> renders the resolved photos.show destination branch inside the dialog.
The intercept view can also ignore the outlet and render purpose-built modal UI:
import {
useParams,
useRouter,
} from '@cookbook/router-react';
export function PhotoModal() {
const router = useRouter();
const params = useParams('photos.show');
return (
<Dialog>
<PhotoSummary
photoId={params.photoId}
/>
<button
type="button"
onClick={() => {
router.navigate.back();
}}
>
Close
</button>
</Dialog>
);
}Use a custom intercept view when the modal experience should differ from the full canonical page.
Configured intercepts
Configured intercepts belong to the source route:
{
id: 'gallery',
path: '/gallery',
layout: {
view: GalleryLayout,
slots: {
modal: true,
},
},
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
},
}The configuration is keyed by slot name.
interface RouteInterceptConfig {
readonly to:
| string
| readonly string[];
readonly view: RouteView;
}One intercept can handle multiple destinations:
intercepts: {
modal: {
to: [
'photos.show',
'videos.show',
],
view: MediaModal,
},
}The intercept applies only while its source route belongs to the active branch.
Navigating to the same target from another part of the application renders the canonical route unless that source branch declares its own intercept.
Slot ownership
The named slot belongs to the source rendering branch, not the destination.
The slot may be declared:
- On the source route’s own layout
- By an active ancestor layout
For example:
{
id: 'app',
path: '/',
layout: {
view: AppLayout,
slots: {
modal: true,
},
},
children: [
{
id: 'gallery',
path: 'gallery',
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
},
},
],
}gallery can configure the intercept because its active app ancestor declares the modal slot.
The layout must also render the slot:
function AppLayout() {
return (
<>
<Outlet />
<Slot name="modal" />
</>
);
}Route validation can verify that the slot is declared. It cannot inspect the layout component to prove that <Slot name="modal" /> is present.
A declared but unrendered slot produces no visible intercept UI.
Automatic interception
Configured intercepts are automatic during client navigation.
<Link
route="photos.show"
params={{ photoId: 42 }}
>
Open photo
</Link>When the active source branch contains a configured intercept for photos.show, the router applies it.
The router searches the active source branch from leaf to root. A configuration closer to the active leaf takes precedence over an ancestor configuration.
When more than one configured slot can intercept the same destination, specify the intended slot at the call site.
Select a configured slot
A string intercept value selects an existing configured intercept:
<Link
route="photos.show"
params={{ photoId: 42 }}
intercept="modal"
>
Open in modal
</Link>The string does not create a new intercept view.
This only works when the active source branch already declares a matching configuration:
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
}This does not provide a view by itself:
<Link
route="photos.show"
params={{ photoId: 42 }}
intercept="drawer"
>
Open
</Link>If no configured drawer intercept targets photos.show, navigation continues canonically.
Use the string form to:
- Disambiguate multiple configured slots
- Make the intended slot explicit
- Reapply a configured intercept from a special call site
Declare an intercept at the call site
Use object form when one navigation needs a custom view that is not declared in route configuration:
<Link
route="photos.show"
params={{ photoId: 42 }}
intercept={{
slot: 'modal',
view: CompactPhotoPreview,
}}
>
Quick preview
</Link>Object form requires:
interface CallSiteInterceptInput {
readonly slot: string;
readonly view: RouteView;
}The source branch still needs to own the named slot.
A call-site object does not require a configured intercepts entry for that destination.
The same option is available programmatically:
await router.navigate.to(
'photos.show',
{
params: {
photoId: 42,
},
intercept: {
slot: 'modal',
view: CompactPhotoPreview,
},
},
);Bypass a configured intercept
Pass false when one navigation should open the canonical destination:
<Link
route="photos.show"
params={{ photoId: 42 }}
intercept={false}
>
Open full page
</Link>This is useful inside an active modal:
function PhotoModal() {
const params = useParams('photos.show');
return (
<Link
route="photos.show"
params={{
photoId: params.photoId,
}}
intercept={false}
>
Open full page
</Link>
);
}The source branch is replaced by the canonical destination branch, and the modal closes.
Navigation from an active intercept
Configured interception is not automatically reapplied from an already intercepted destination.
For example:
/galleryintercepts/photos/42intomodal.- A link inside the modal navigates to
/photos/42?mode=full. - That navigation renders the canonical page unless it explicitly supplies another intercept option.
This prevents the destination from becoming trapped inside the current modal.
Use:
intercept={false}when canonical navigation should be explicit.
Use:
intercept="modal"when another configured modal interception is deliberately required.
URL and router state
An intercepted navigation commits the destination URL:
await router.navigate.to(
'photos.show',
{
params: {
photoId: 42,
},
},
);The resulting state resembles:
router.state.location.href;
// '/photos/42'
router.state.match?.route.id;
// 'photos.show'
router.state.previousLocation?.href;
// '/gallery'
router.state.match?.intercepted;
// {
// slot: 'modal',
// sourceRouteId: 'gallery',
// targetRouteId: 'photos.show',
// previousHref: '/gallery',
// match: destinationMatch,
// view: PhotoModal,
// }The core router state describes the destination.
RouterProvider uses previousLocation to reconstruct and render the source branch, then places the intercept view into the resolved source slot.
This means global state hooks still observe the destination:
const location = useLocation();
// /photos/42
const matches = useMatches();
// destination branchInside the intercept view, local route hooks also read the destination:
const params = useParams('photos.show');
const search = useSearchParams('photos.show');
const hash = useHashParams('photos.show');The source remains visually rendered, but the accepted router destination is the target.
Middleware and lifecycle still run
Interception does not bypass the navigation pipeline.
The destination still goes through:
- Matching
- Path, search, and hash parsing
- Blockers
- Lifecycle hooks
- Middleware
- History commit
- Error handling
Lifecycle processes the normal source-to-destination transition.
That means source beforeLeave and destination beforeEnter hooks can run even though React later preserves the source branch visually.
Interception is a rendering decision after the destination transition has been accepted. Do not treat it as proof that the source route never left router state.
Back and forward behavior
Default link and router.navigate.to() navigation pushes a destination history entry containing intercept metadata.
History entry 1: /gallery
History entry 2: /photos/42 + intercept stateGoing back closes the intercepted UI:
router.navigate.back();The router returns to:
/galleryGoing forward restores the intercepted destination while its required history state and view remain available.
This gives modal navigation normal browser behavior:
- Back closes
- Forward reopens
- The destination URL can be copied
- The canonical route remains directly addressable
Replace navigation
A replaced intercept does not preserve the source as a separate history entry:
<Link
route="photos.show"
params={{ photoId: 42 }}
replace
>
Open photo
</Link>The renderer can still preserve the source branch for the active intercept, but browser Back may navigate to an entry before the source rather than merely closing the modal.
Use normal push navigation for dismissible overlays whose expected close action is Back.
Direct visits and native navigation
Interception requires:
- An active source match
- A client-side router navigation
- A valid destination match
- A usable source-owned slot
A direct visit with no intercept history state renders the canonical destination:
GET /photos/42
↓
PhotoPageThe same applies to native link behavior such as:
- Open in new tab
- Open in new window
- Modifier-click
- Copy link and open elsewhere
- Server/static rendering without an active source branch
Link renders the canonical destination href into its anchor. Interception only occurs when the router handles the client-side click.
This preserves progressive URL behavior: the link always points to a real page.
Intercept context
Pass navigation-specific data through context:
<Link
route="photos.show"
params={{ photoId: 42 }}
context={{
source: 'featured-grid',
position: 3,
}}
>
Open photo
</Link>Read it from the intercept view:
interface PhotoInterceptContext {
readonly source: string;
readonly position: number;
}
function PhotoModal() {
const context = useOutletContext<PhotoInterceptContext>();
return (
<p>
Opened from {context.source}
</p>
);
}Navigation context takes precedence over context supplied directly by <Slot> for the intercepted render.
Without navigation context, the intercept view can receive the slot context normally.
Context must be history-safe
Intercept context is stored in browser history state so Back and Forward can restore it.
Use structured-clone-safe data:
context={{
source: 'gallery',
photoId: 42,
filters: ['featured'],
}}Do not pass:
- Functions
- React elements
- DOM nodes
- Closures
- Other runtime-only objects that browser history cannot clone
The call-site intercept view itself is not written into browser history. Cookbook Router stores an in-memory key for the view and keeps the history state cloneable.
Because that view registry is in memory, an inline call-site intercept view should not be expected to survive a full document reload. When it cannot be restored, the canonical destination renders.
Configured intercept views are recoverable from route configuration when matching intercept history state is available.
Slot rendering precedence
While an intercept is active, its view takes priority in the selected slot.
The slot does not simultaneously render:
- Its normal fallback view
- Its matched slot route
- Its disabled or empty output
- The intercepted view
The intercepted view replaces the slot’s normal output for that render.
The main source outlet remains rendered separately.
Loading and errors
An intercept view uses the destination route’s render context and route boundaries.
A destination route can provide:
{
id: 'photos.show',
path: '/photos/{photoId:int}',
view: PhotoPage,
loading: PhotoLoading,
error: PhotoError,
}The source slot can also isolate render errors:
<Slot
name="modal"
errorFallback={PhotoModalError}
/>When a slot-local error fallback is provided, it owns render errors for the intercepted slot subtree.
Pass null to catch the failure and render nothing:
<Slot
name="modal"
errorFallback={null}
/>See Loading and errors for fallback precedence.
Preloading
Normal route preloading warms the canonical destination branch:
<Link
route="photos.show"
params={{ photoId: 42 }}
prefetch="interaction"
>
Open photo
</Link>Link prefetch does not automatically preload a configured or inline intercept view.
Warm a lazy intercept view explicitly when necessary:
const PhotoModal = lazyRouteView(() => import('./photo-modal'));
export const galleryRoute = defineRoute({
id: 'gallery',
path: '/gallery',
preload: async () => {
await PhotoModal.preload();
},
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
},
});Only do this when the intercept is likely to be used. Preloading every modal removes the benefit of code splitting.
Validation
Configured intercepts are validated with the route tree.
Cookbook Router rejects:
- An empty slot name
- A non-object intercept configuration
- A configuration without
view - A configuration without at least one target ID
- An empty target route ID
- A target route ID that does not exist
- A slot not declared by the source route or an active ancestor layout
Valid:
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
}Invalid:
intercepts: {
modal: {
to: 'missing.route',
view: PhotoModal,
},
}Call-site intercepts are validated at navigation time.
An object call-site intercept without a view throws:
intercept={{
slot: 'modal',
}}A call-site request for an unavailable slot throws in development. Production falls back to canonical navigation instead of breaking the transition.
Choose configured or call-site interception
Use a configured intercept when
- A source route consistently presents a target in one slot
- The behavior should apply automatically
- Back and Forward should restore the same configured view
- The interaction is part of the route architecture
intercepts: {
modal: {
to: 'photos.show',
view: PhotoModal,
},
}Use a string call-site option when
- Multiple configured slots can handle the destination
- One navigation must explicitly select a configured slot
intercept="modal"Use an object call-site intercept when
- One link needs a unique preview
- The route configuration should not own the alternate view
- The destination should use a different slot for one interaction
intercept={{
slot: 'drawer',
view: CompactPreview,
}}Use false when
- The canonical page must open
- A link inside a modal should leave interception
- One interaction must bypass automatic source configuration
intercept={false}Where this bites
The URL changes even though the source page remains visible
That is expected. Interception commits the canonical destination URL and preserves the source only for rendering.
router.state.match is the destination
The React provider renders the source from previousLocation. Core router state still describes the canonical target.
A direct visit does not open the modal
There is no active source branch to own the slot. The canonical destination renders.
intercept="modal" renders the canonical page
The string form only selects an existing configured intercept. It does not provide a view.
The route declares a slot but no modal appears
The active layout component must render <Slot name="modal" />.
A link inside the modal opens the full page
Automatic interception is deliberately not reapplied from an active intercepted destination. Supply an explicit intercept when another intercepted navigation is intended.
Back does not only close the modal
The intercepted navigation used replace semantics, so the source does not have its own immediately preceding history entry.
Inline interception disappears after a full reload
Call-site views are retained in memory and referenced from history state by key. Use configured intercepts when reload restoration matters.
Source lifecycle hooks run even though the source UI stays visible
The router performs a real source-to-destination transition. Source preservation is a rendering behavior, not lifecycle retention.
Context causes a browser history error
Use structured-clone-safe values. Do not place functions, components, or DOM objects in navigation context.