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 from route — 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 its Pending shows 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 in history.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.

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 @slot routes are a declared non-goal. Next.js pairs interception with independent slot navigation (@team/@analytics, per-slot loading.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' }