Intercepting routes
Modal-with-URL — a page that opens as an overlay above the still-mounted origin on soft navigation, renders standalone on a hard load, and closes on Back; declared with one page export, no directory grammar.
An intercepting route is the modal-with-URL pattern: navigating from a gallery to a photo opens the photo as an overlay above the still-mounted gallery — the URL is the photo's, sharing/reloading it shows the standalone photo page, and Back closes the overlay with the gallery exactly as you left it (typed filters, scroll position, mounted state — nothing re-runs).
Declaring one
One export on the PAGE, no directory grammar:
// src/pages/photos/[id]/page.tsx
export const renderMode = 'ssr' as const
export const intercept = { from: '/photos' }
export const loader = ({ params }) => fetchPhoto(params.id)
export default function PhotoDetail() {
const photo = useLoaderData<Photo>()
return <figure>…</figure>
}from names one or more ROUTE PATTERNS ('/photos',
['/photos', '/albums/[id]']). A soft navigation that arrives from one of
them renders this page inside a native <dialog> overlay; a soft navigation
from anywhere else — and every hard load — renders it standalone. That
asymmetry is the feature: the same URL is a lightweight preview in context
and a full page out of context.
The three paths, precisely
- Soft navigation from a
fromroute — the overlay opens. The origin page stays MOUNTED: its state, subscriptions and scroll position are untouched (the router keeps rendering it as the background tree; nothing unmounts, no loader re-runs). Only the modal page's own loader runs — layout loaders do not (the overlay renders the page alone above the background's chrome), and itsPendingshows inside the overlay, never as a full-screen swap. - Hard load / reload — standalone, always. The server knows nothing of
interception; it renders the page as itself, with its own
meta. A reload of an open modal deliberately IGNORES the overlay state that survives inhistory.state— the server rendered standalone and hydration must match it. - Back — closes the overlay (it is a real history entry). Focus returns
to the element that opened it (native
<dialog>semantics), the body scroll lock releases, and the background — which never went anywhere — needs no restore.
Nested modals stack: a modal that soft-navigates to another intercepting
route (its from naming the modal's pattern) opens above it, and Back
closes only the topmost.
Navigation blockers hold the Back gesture
useBlocker now guards popstate too. Back is a modal's primary close
gesture, and before this it bypassed every blocker: the browser moves the
URL first, so the router reverts the move (history.go(-delta)) when a
blocker holds it and surfaces retry/reset as for any blocked navigation.
ESC inside the overlay routes through the same path — a dirty form holds
both. This is a behaviour CHANGE of a documented hook: a blocker that used
to be silently skipped on Back now fires.
Two trees, two query strings
While an overlay is open the URL carries the MODAL's query. Each tree reads
its own: useSearchParams in the background keeps decoding the background's
query (an open modal cannot reset a filter), and useSetSearchParams writes
to the calling tree's URL — a background setter never writes onto the
modal's URL, and a modal setter (a ?zoom= tweak) replaces without tearing
down its own background.
What it is NOT
- Parallel
@slotroutes are a declared non-goal. Next.js pairs interception with independent slot navigation (@team/@analytics, per-slotloading.tsx/default.tsx). Here, dashboard split panes are COMPONENTS in a layout, not a routing concept — this page delivers the intercepting/modal half only. - Islands / zero-JS pages don't intercept. Interception is a client
router behaviour;
interactive: 'islands'pages have no SPA navigation and'none'ships no JS. Full-hydration pages only. - Overlays do not View-Transition. Route transitions apply to route swaps; an overlay opening is a layer change, not a page change (see Navigation).
Chrome, focus, and styling
The framework renders the overlay as a native <dialog> opened with
showModal() — focus trap, ::backdrop and focus restoration come from the
platform, and the body scroll is locked while open. It is deliberately
unstyled: target dialog[data-vweb-overlay] (and ::backdrop) from your
CSS or a kit. The route announcer announces the modal's title on open, as it
would any navigation.
Locale-prefixed apps
from matches route patterns literally, so a [locale] mirror declares its
own: /de/photos/[id]'s page re-exports the base page and sets
intercept: { from: '/[locale]/photos' }.
Declares — not re-exports. intercept is read off the page module at
runtime, so any re-export forwards it, export * included. A mirror that
forwards the base page's intercept therefore inherits from: '/photos', and
from is compared against the background's route PATTERN, which for a mirror is
/[locale]/photos. The two never match, so the overlay silently never opens and
the modal renders standalone — no error, no warning, just a page where a modal
was expected. This is the opposite failure from the searchParams
re-export, which is silently
lost; intercept is silently inherited with the wrong pattern.
// src/pages/[locale]/photos/[id]/page.tsx
export { default, meta } from '../../../photos/[id]/page'
// NOT re-exported: the base's `from` names the un-prefixed pattern.
export const intercept = { from: '/[locale]/photos' }