Intercepting Routes

Show a route as a modal over the current page on client navigation, while a direct visit renders the full page.

You have seen this on every serious app: click a photo in a grid and it opens in a modal, but copy the URL and open it in a new tab and you get the full standalone page. That dual behavior — same URL, two presentations depending on how you arrived — is exactly what intercepting routes give you, with zero client state juggling.

The dot conventions

You mark a folder that should intercept another segment using a matcher that reads like relative paths:

MarkerIntercepts
(.)foldersame level
(..)folderone level up
(..)(..)foldertwo levels up
(...)folderfrom the app root

These count route segments, not filesystem folders — route groups (name) and slots @name do not count as levels. That mismatch is the classic gotcha when your interception silently does nothing.

The winning combo: parallel + intercepting

The production-grade modal pattern pairs an intercepting route with a parallel @modal slot. On soft (client) navigation the intercepting route fills the slot and you render a modal. On a hard load or refresh, the interception is bypassed and the real page renders full-screen. The slot's default.tsx returns null so nothing shows when no modal is active.

Example

Example · typescript
// Layout with a modal slot:
// app/feed/layout.tsx  -> { children, modal }
//
// Structure:
//   app/feed/@modal/(.)photo/[id]/page.tsx   <- intercepts /feed/photo/[id]
//   app/feed/photo/[id]/page.tsx             <- the real, full page
//   app/feed/@modal/default.tsx              <- returns null

// app/feed/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal';

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);
  return (
    <Modal>
      <img src={photo.url} alt={photo.alt} />
    </Modal>
  );
}

// The Modal component (client) calls router.back() to dismiss,
// which unwinds the slot back to default.tsx (null).

When to use it

  • A photo gallery shows an image in a modal via an intercepting route when clicked in the feed, and as a full page when opened directly.
  • A social app intercepts /profile/[id] to show a mini card modal from the feed without leaving the current page.
  • An e-commerce site intercepts /products/[id] to open a quick-view drawer, with a full product page on direct URL access.

More examples

Intercepting route convention

The (.) prefix on a folder tells Next.js to intercept navigation to that path and render it in place.

Example · bash
app/
├── feed/
│   └── page.tsx                  # /feed
└── (.)photos/
    └── [id]/
        └── page.tsx              # intercepts /photos/[id] from /feed

# (.) = same level  (..) = one level up  (...) = root

Modal rendered by interceptor

When navigated to from the feed, this interceptor renders the photo inside a Modal over the current page.

Example · ts
// app/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal';
import Photo from '@/components/Photo';

export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const photo = await getPhoto(id);
  return (
    <Modal>
      <Photo photo={photo} />
    </Modal>
  );
}

Full page on direct visit

A direct visit or page refresh skips the interceptor and loads the full standalone photo page.

Example · ts
// app/photos/[id]/page.tsx  — direct URL access
export default async function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const photo = await getPhoto(id);
  return (
    <main>
      <img src={photo.url} alt={photo.alt} />
      <p>{photo.description}</p>
    </main>
  );
}

Discussion

  • Be the first to comment on this lesson.