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:
| Marker | Intercepts |
|---|---|
(.)folder | same level |
(..)folder | one level up |
(..)(..)folder | two levels up |
(...)folder | from 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
// 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.
app/
├── feed/
│ └── page.tsx # /feed
└── (.)photos/
└── [id]/
└── page.tsx # intercepts /photos/[id] from /feed
# (.) = same level (..) = one level up (...) = rootModal rendered by interceptor
When navigated to from the feed, this interceptor renders the photo inside a Modal over the current page.
// 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.
// 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