Skip to content

Route interception

Opening a route as a modal over the page you were on.

Updated View as Markdown

Route interception lets you load a route from a different part of your app within the current layout. When a user clicks a link, the intercepted component renders in a parallel slot (like a modal) while the current page stays visible behind it. On hard navigation (refresh or direct URL), the normal page renders instead.

This follows the same convention as Next.js — using (.)folder, (..)folder, and (...)folder prefixes.

Convention

Prefix Intercepts
(.)folder Same level — matches a sibling route
(..)folder One level up — matches a route in the parent segment
(…)folder Root level — matches a route from the app root

A photo modal

The most common use case is showing content in a modal on SPA navigation, with the full page as a fallback on hard navigation.

File Structure

File structuretsx
src/app/
├── layout.tsx              ← renders {children} + {modal}
├── page.tsx                ← feed page
├── @modal/
│   ├── default.tsx         ← empty by default (no modal open)
│   └── (.)photo/[id]/
│       └── page.tsx        ← photo modal (interceptor)
└── photo/[id]/
    └── page.tsx            ← full photo page (hard nav)

Root Layout

The layout receives the modal parallel slot as a prop. No special wrapper component needed — just render it directly:

app/layout.tsxtsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      <main>{children}</main>
      {modal}
    </div>
  );
}

Default Slot

The @modal/default.tsx renders when no interception is active:

app/@modal/default.tsxtsx
export default function ModalDefault() {
  return null;
}

Interceptor Component

The interceptor at @modal/(.)photo/[id]/page.tsx receives the target route’s params. It can be a server component — only the frame around it needs to be interactive:

app/@modal/(.)photo/[id]/page.tsxtsx
import { ModalShell } from '../../../components/ModalShell';
import { findPhoto } from '../../../../data';

export default async function PhotoModal({ id }: { id: string }) {
  const photo = await findPhoto(id);

  return (
    <ModalShell>
      <h2>{photo.title}</h2>
      <p>This renders in a modal on SPA navigation.</p>
    </ModalShell>
  );
}

Read the same data the full page reads. An interceptor pointed at a different source than the page it stands in for looks exactly like a broken interception: the modal opens correctly and says the record does not exist.

Closing It

The engine puts the interceptor in the slot; the affordances for dismissing it are yours to write. A modal needs three, and the last two are the ones that get forgotten — a close control, the Escape key, and a click on the backdrop:

components/ModalShell.tsxtsx
"use client";

import { useEffect, useRef } from 'react';
import type { ReactNode } from 'react';

export function ModalShell({ children }: { children: ReactNode }) {
  const close = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    // The only way to dismiss a dialog without a pointer.
    const onKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') history.back();
    };

    window.addEventListener('keydown', onKey);
    close.current?.focus();

    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return (
    <div className="modal-backdrop" onClick={() => history.back()}>
      {/* A click inside the dialog is not a click on the backdrop. */}
      <article role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
        <button ref={close} aria-label="Close" onClick={() => history.back()}>
          ×
        </button>
        {children}
      </article>
    </div>
  );
}

Closing is history.back() rather than a link to a fixed URL. The modal was opened by pushing a history entry over the page beneath, so going back is both what the browser’s own back button does and what returns to whatever page it was opened over — a hardcoded destination is wrong the moment the same modal is reachable from two places.

The router recognises that as leaving an interception and empties the slot without asking the server for anything. The page underneath never left, so closing costs no request and everything typed into it is still there.

A modal reachable from exactly one place can link to that place instead, and a real <Link> is worth keeping for it — a link can be opened in a new tab, and it still works before the page hydrates. history.back() is for a shell used from several.

Full Page (Hard Nav)

When a user navigates directly to /photo/123 (hard refresh, shared link), the normal page renders:

app/photo/[id]/page.tsxtsx
export default function PhotoPage({ id }: { id: string }) {
  return (
    <div>
      <h1>Photo {id}</h1>
      <p>Full photo page — shown on direct navigation or refresh.</p>
    </div>
  );
}

How it works

On SPA navigation (clicking a Link):

  • The client checks the intercept manifest (generated at build time)
  • If the target URL matches an intercept pattern, headers are added to the request
  • The server resolves the current page from the referer URL, to know which slot the interceptor belongs in
  • It renders the interceptor alone, and says so with X-RSC-Revalidate: modal
  • The client puts it in that slot. The page underneath is never re-rendered — so a half-filled form on it is still half-filled

On hard navigation (direct URL, refresh):

  • Normal route matching — /photo/123 renders photo/[id]/page.tsx
  • No interception — the full photo page renders

Re-rendering the page underneath would put the modal on screen at the cost of rebuilding everything below the layout that declares the slot. That page is already mounted and still correct; only the slot is new.

Nested interception

Use (..) to intercept routes one level up, or (...) to intercept from the app root:

Intercepting from a nested pagetsx
// From /feed, intercept /photo/[id] at the same level
app/@modal/(.)photo/[id]/page.tsx

// From /feed/trending, intercept /photo/[id] one level up
app/feed/@modal/(..)photo/[id]/page.tsx

// From /dashboard/settings, intercept /photo/[id] from root
app/dashboard/settings/@drawer/(...)photo/[id]/page.tsx

Several slots

You can intercept the same route into different slots:

File structuretsx
app/
├── @modal/(.)photo/[id]/page.tsx     ← renders in "modal" slot
├── @preview/(.)photo/[id]/page.tsx   ← renders in "preview" slot
└── photo/[id]/page.tsx               ← full page

Seeing it work

The example app in the repository has a working one: @modal/(.)posts/[slug] over a feed of posts. Click a post to open it in a modal, then refresh to get the same route as a page.


Route interception builds on parallel routes. The @folder convention is worth being comfortable with first — an interceptor is a page inside a slot, and everything a slot does applies to it.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close