Intercepting Routes — (.)(..)

Loads another route inside the current layout while leaving the browser URL masked.

Signature
(.)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 directory

Parameters

ParameterTypeDescription
$(.)matcherMatches segments on the same level as the intercepting folder.
$(..)matcherMatches segments one level above. Analogous to ../ but counted in route segments, not directories.
$(..)(..)matcherMatches segments two levels above. There is no (...)(...) form — repeat (..) instead.
$(...)matcherMatches 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 /login page.
  • 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

Example · typescript
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.tsx

When 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.

Example · typescript
// 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.

Example · typescript
// 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  correct

Intercepting 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.