Intercepting Routes — (.)(..)
Loads another route inside the current layout while leaving the browser URL masked.
(.)folder match a segment on the same level
(..)folder match a segment one level above
(..)(..)folder match a segment two levels above
(...)folder match a segment from the root app directoryParameters
| Parameter | Type | Description |
|---|---|---|
$(.) | matcher | Matches segments on the same level as the intercepting folder. |
$(..) | matcher | Matches segments one level above. Analogous to ../ but counted in route segments, not directories. |
$(..)(..) | matcher | Matches segments two levels above. There is no (...)(...) form — repeat (..) instead. |
$(...) | matcher | Matches segments from the root app directory, whatever the depth of the intercepting folder. |
An intercepting route lets a click render a different route's content inside the layout you are already on, while the address bar shows the target URL. Clicking a photo in a feed overlays /photo/123 as a modal over /feed; opening that same link cold, or refreshing, renders the real photo page instead. No interception happens on a hard navigation — that asymmetry is the whole feature.
When to reach for it
- Image or product modals that must stay shareable as URLs.
- A login modal in the navbar alongside a full
/loginpage. - A cart that slides in from the side but also has its own page.
What trips people up
The matcher counts route segments, not folders. Because a parallel-route @modal folder is a slot and not a segment, app/feed/@modal/(..)photo reaches app/photo with a single (..) even though it is two directories up. Counting directories is the usual reason an interception silently never fires.
Interception alone does not give you a modal. It needs a parallel slot to render into, plus a default.js in that slot so the modal is absent when inactive.
Example
import { Modal } from '@/app/ui/modal'
import { Photo } from '@/app/ui/photo'
export default async function InterceptedPhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<Modal>
<Photo id={id} />
</Modal>
)
}
// Clicking a link to /photo/123 from /feed -> this file, as an overlay
// Loading /photo/123 directly or refreshing -> app/photo/[id]/page.tsxWhen to use it
- Open a photo as a modal over the gallery while /photo/123 stays shareable.
- Show a login modal from the navbar without abandoning the current page.
- Slide a shopping cart over the product list, with /cart still working directly.
- Preview a record in an overlay from a table, keeping the deep link intact.
Frequently asked questions
What do (.) and (..) mean in a Next.js folder name?
They are intercepting-route matchers: (.) matches segments on the same level, (..) one level above, (..)(..) two levels above, and (...) from the root app directory. They resemble relative paths but apply to route segments.
Why is my intercepting route not working?
Usually because the matcher counts directories instead of route segments. Parallel-route @slot folders are not segments and must not be counted, so app/feed/@modal/(..)photo reaches app/photo despite being two folders deep.
How do I make a modal that survives a page refresh?
Pair an intercepting route with a parallel slot: the intercepted version renders the modal on client-side navigation, while the real route at the same URL renders the full page on refresh or direct link.
More examples
The folder layout that makes it work
Three pieces are load-bearing: the @modal slot gives the intercepted route somewhere to render, default.tsx keeps it empty otherwise, and app/photo/[id] still exists for hard navigations. Note the matcher here is (.) and not (..): @modal is a slot, so (.)photo is already at the same segment level as app/photo.
// app/
// ├── layout.tsx renders {children} and {modal}
// ├── @modal/
// │ ├── default.tsx returns null when no modal is active
// │ └── (.)photo/
// │ └── [id]/page.tsx the intercepted, modal version
// ├── photo/
// │ └── [id]/page.tsx the real page: refresh, or direct link
// └── page.tsx the feed
// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}Counting segments, not directories
The two 'wrong' lines are the failure everyone hits, and neither errors — the route simply never intercepts, so clicks fall through to a full page navigation. Reading the path as route segments makes it mechanical: app/feed/@modal sits at segment depth one (/feed), so reaching /photo is exactly one level up.
// Target route: app/photo/[id]/page.tsx -> /photo/123
// From a sibling segment — same level:
// app/feed/(.)photo/[id]/page.tsx wrong: /feed/photo, not /photo
// app/feed/(..)photo/[id]/page.tsx correct
// Through a parallel slot — @modal is NOT a segment:
// app/feed/@modal/(..)photo/[id]/page.tsx correct
// app/feed/@modal/(..)(..)photo/[id]/page.tsx wrong: counts the slot folder
// From anywhere, regardless of depth:
// app/a/b/c/@modal/(...)photo/[id]/page.tsx correctIntercepting a login route from the navbar
The <Login> form is shared by both files, so there is one implementation and two presentations. Keeping <Modal> separate from the content also keeps the form a Server Component: only the modal shell needs 'use client' for router.back(), which matters when the form uses a Server Action.
// app/@auth/(.)login/page.tsx
import { Modal } from '@/app/ui/modal'
import { Login } from '@/app/ui/login'
export default function Page() {
return (
<Modal>
<Login />
</Modal>
)
}
// app/login/page.tsx — the standalone page
import { Login } from '@/app/ui/login'
export default function Page() {
return <Login />
}
Discussion