| | |

Next.js 24 ▲ Advanced Routing — Parallel routes & intercepted routes

⏱️ Estimated read: 13 min | |

The App Router’s routing model goes beyond nested segments and layouts. Two conventions extend it into composition: parallel routes, which render multiple pages in the same layout through named slots, and intercepting routes, which display a route from another part of the tree within the current layout. The two are most valuable when combined. Parallel routes alone enable dashboards with independently streaming panels; intercepting routes alone enable a route to appear in a different context. Together they produce the modal pattern that preserves the URL, survives a refresh, and closes correctly with the back button .

This chapter covers the @slot convention, the default.js requirement that prevents hard-navigation 404s, the (.) and (..) interception matchers and the route-segment counting rule that confuses them with file paths, and the modal implementation that closes with router.back() rather than a navigation.

Key point: A parallel slot is not a route segment. It does not appear in the URL, and the (..) interception matcher counts route segments, not file-system directories. Inside @modal, a (.) matches the same route level as the parent, even though the file-system path is one directory deeper.


Why these two conventions exist

The composition problem. A nested layout renders its children in one place. A dashboard that needs an analytics panel and an activity feed rendered side by side, each with its own loading state and error boundary, cannot express that with children alone. The @folder convention creates a named slot, and the layout receives it as a prop. The slot streams independently, and its loading.js and error.js are scoped to that slot rather than to the whole page .

The modal problem. A photo in a gallery should open as an overlay when clicked from the feed, but it should render as a full page when the URL is shared or refreshed. Intercepting routes solve this: a soft navigation (a <Link> click) matches the intercepted route and renders it inside the current layout, while a hard navigation (a direct URL or a refresh) bypasses the interception and renders the full page . The URL updates either way, which is what makes the modal shareable.

The counting problem. The (.), (..), (..)(..), and (...) matchers are based on route segments, not file-system directories. A slot folder like @modal is not a segment. The Next.js documentation states this explicitly: “The (..) convention is based on route segments, not the file-system. For example, it does not consider @slot folders in Parallel Routes” . A developer who counts directories instead of segments will use one too many (..) and the interception will not match.


a. The parallel slot and the default.js requirement

A parallel route is a folder whose name begins with @. The folder’s content is passed to the parent layout as a prop named after the folder without the @. The slot does not appear in the URL: app/dashboard/@analytics/views/page.tsx is served at /dashboard/views, not /dashboard/@analytics/views .

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="grid">
      <aside>{team}</aside>
      <main>{children}</main>
      <section>{analytics}</section>
    </div>
  )
}

The default.js file is mandatory for every slot. When a user performs a hard navigation, Next.js cannot determine the active state of the slots that do not match the current URL. It renders the slot’s default.js as a fallback. Without the file, the unmatched slot produces a 404 . The children prop is an implicit slot, so the route segment itself also needs a default.js for the case where the parent page’s active state cannot be recovered .

// app/dashboard/@analytics/default.tsx
export default function Default() {
  return null
}

The Next.js 16 build fails if a slot’s default.js is missing, which turns the runtime 404 into a build-time error .


b. The interception matchers and the segment counting

The four matchers map to route-segment levels, not file-system levels.

MatcherMatches
(.)The same route level
(..)One route level above
(..)(..)Two route levels above
(...)The root app directory

In the photo gallery example, the full page is at app/photo/[id]/page.tsx. The modal slot is at app/@modal/. The intercepted route is placed at app/@modal/(.)photo/[id]/page.tsx. The (.) is correct because the photo segment is at the same route level as the slot’s parent, even though the file-system path is one directory deeper (@modal adds a directory but not a segment) . A (..) would be used when intercepting from a route that is one segment deeper, such as a feed route that opens a photo route from its parent level .

The layout renders the modal slot alongside children. The modal content is a Client Component that closes with router.back().

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}

c. The modal component and the close behavior

The modal is a Client Component. It needs the useRouter hook to close itself, and it should close with router.back() rather than a <Link> or router.push('/'). A push adds a new history entry, which breaks the back-button behavior: the user presses back and the modal reopens instead of navigating to the previous page .

// components/modal.tsx
'use client'

import { useRouter } from 'next/navigation'
import { useEffect, useCallback, useRef } from 'react'

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const overlayRef = useRef<HTMLDivElement>(null)

  const handleClose = useCallback(() => {
    router.back()
  }, [router])

  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') handleClose()
    }
    document.addEventListener('keydown', onKeyDown)
    return () => document.removeEventListener('keydown', onKeyDown)
  }, [handleClose])

  return (
    <div
      ref={overlayRef}
      onClick={(e) => e.target === overlayRef.current && handleClose()}
      className="fixed inset-0 bg-black/50 flex items-center justify-center"
    >
      <div className="bg-white rounded-lg p-6">{children}</div>
    </div>
  )
}

The intercepted route renders the modal component with the photo content. The full page route renders the same content without the modal wrapper.

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

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.title} />
    </Modal>
  )
}
// app/photo/[id]/page.tsx
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)
  return (
    <div className="full-page">
      <img src={photo.url} alt={photo.title} />
      <h1>{photo.title}</h1>
    </div>
  )
}

The soft navigation from the gallery renders the modal. A refresh or a direct visit to /photo/123 renders the full page. The URL is the same in both cases, which is the property that makes the modal shareable and the page indexable .


Complete Example Session

// ============================================
// PART 1: THE DASHBOARD SLOTS
// ============================================
// app/dashboard/@analytics/page.tsx
export default function Analytics() {
  return <div>Analytics panel</div>
}

// app/dashboard/@team/page.tsx
export default function Team() {
  return <div>Team panel</div>
}
// ============================================
// PART 2: THE DASHBOARD LAYOUT
// ============================================
// app/dashboard/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="grid">
      <aside>{team}</aside>
      <main>{children}</main>
      <section>{analytics}</section>
    </div>
  )
}
// ============================================
// PART 3: THE DEFAULT FILES
// ============================================
// app/dashboard/@analytics/default.tsx
export default function Default() {
  return null
}

// app/dashboard/@team/default.tsx
export default function Default() {
  return null
}
// ============================================
// PART 4: THE MODAL SLOT
// ============================================
// app/@modal/default.tsx
export default function Default() {
  return null
}
// ============================================
// PART 5: THE ROOT LAYOUT WITH THE SLOT
// ============================================
// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  )
}
// ============================================
// PART 6: THE INTERCEPTED ROUTE
// ============================================
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal'
import { getPhoto } from '@/lib/data'

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.title} />
    </Modal>
  )
}
// ============================================
// PART 7: THE FULL PAGE ROUTE
// ============================================
// app/photo/[id]/page.tsx
import { getPhoto } from '@/lib/data'

export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)
  return (
    <div className="full-page">
      <img src={photo.url} alt={photo.title} />
      <h1>{photo.title}</h1>
    </div>
  )
}
// ============================================
// PART 8: THE MODAL COMPONENT
// ============================================
'use client'
import { useRouter } from 'next/navigation'

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  return (
    <div onClick={() => router.back()} className="overlay">
      <div onClick={(e) => e.stopPropagation()}>{children}</div>
    </div>
  )
}
// ============================================
// PART 9: THE LINK FROM THE GALLERY
// ============================================
// app/gallery/page.tsx
import Link from 'next/link'

export default function Gallery({ photos }: { photos: Photo[] }) {
  return photos.map((p) => (
    <Link key={p.id} href={`/photo/${p.id}`}>
      <img src={p.thumbnail} alt={p.title} />
    </Link>
  ))
}
// ============================================
// PART 10: THE DECISION
// ============================================
// Dashboard panels → @slot + default.js
// Modal with shareable URL → @modal + (.) + router.back()
// Direct URL → full page route

The ten parts covered the dashboard slots, the layout, the default files, the modal slot, the root layout, the intercepted route, the full page route, the modal component, the gallery link, and the decision.


Quick Reference

Parallel routes

ConventionEffect
@folderNamed slot, passed as a layout prop
Slot in URLNot present
default.jsFallback for hard navigation
Missing default.js404 on refresh (build error in Next.js 16)

Intercepting routes

MatcherMatches
(.)Same route level
(..)One level up
(..)(..)Two levels up
(...)App root

Modal behavior

NavigationRendered
Soft (<Link>)Intercepted route (modal)
Hard (refresh, direct URL)Full page route
Closerouter.back()
<Link> to closeIncorrect (adds history entry)

The segment counting rule

File systemRoute segments
app/@modal/(.)photo/[id]@modal is a slot, not a segment
(.) matchesThe photo segment at the parent level

Best Practices

✅ Do This:

// Add default.js to every slot
export default function Default() { return null }             // ✅
// Use (.) inside @modal to intercept the sibling segment
app/@modal/(.)photo/[id]/page.tsx                            // ✅
// Close the modal with router.back()
const router = useRouter(); router.back()                    // ✅
// Keep the full page route for direct navigation
app/photo/[id]/page.tsx                                      // ✅

❌ Don’t Do This:

// Omit the default.js file
// The slot 404s on refresh                                // ❌
// Count file-system directories for the matcher
// @modal is not a segment, so (..) is wrong here           // ❌
// Close the modal with router.push('/')
router.push('/')                                             // ❌ (breaks back button)
// Use a parallel slot as a URL segment
// /dashboard/@analytics does not exist                     // ❌

Common Pitfalls

PitfallWhy It HappensFix
404 on refreshSlot missing default.jsAdd the file
Interception does not matchWrong number of (..)Count route segments, not directories
Modal reopens on backClosed with push instead of backUse router.back()
Full page renders inside the layoutInterception applied on hard navigationExpected; hard navigation bypasses interception
Build fails in Next.js 16Slot missing default.jsAdd the file before building

Real-World Examples

1. The dashboard slot

app/dashboard/@analytics/page.tsx

2. The slot default

app/dashboard/@analytics/default.tsx

3. The modal slot

app/@modal/default.tsx

4. The intercepted route

app/@modal/(.)photo/[id]/page.tsx

5. The full page

app/photo/[id]/page.tsx

6. The layout

{children}
{modal}

7. The close

router.back()

8. The link

<Link href={`/photo/${p.id}`}>...</Link>

9. The matcher

(.) same level; (..) one up; (...) root

10. The rule

@slot is not a segment

Visual

The parallel slot

┌─────────────────────────────────────────────────────────────┐
│  app/dashboard/                                              │
│    ├── @analytics/                                           │
│    │     ├── page.tsx                                        │
│    │     └── default.tsx                                     │
│    ├── @team/                                                │
│    │     ├── page.tsx                                        │
│    │     └── default.tsx                                     │
│    ├── layout.tsx  ← receives analytics, team, children     │
│    └── page.tsx                                              │
│                                                              │
│  URL: /dashboard (slots not in the URL)                      │
└─────────────────────────────────────────────────────────────┘

The interception matcher

┌─────────────────────────────────────────────────────────────┐
│  app/@modal/(.)photo/[id]/page.tsx                           │
│       │                                                      │
│       └── (.) matches the same route level                   │
│                                                              │
│  @modal is a slot, not a segment.                            │
│  The photo segment is one route level up, not two.           │
└─────────────────────────────────────────────────────────────┘

The modal flow

┌─────────────────────────────────────────────────────────────┐
│  Gallery ──<Link>──► /photo/123                             │
│       │                                                      │
│       ├── Soft navigation ──► intercepted modal              │
│       │                                                      │
│       └── Hard navigation ──► full page                      │
│                                                              │
│  URL is /photo/123 in both cases.                            │
└─────────────────────────────────────────────────────────────┘

The close behavior

┌─────────────────────────────────────────────────────────────┐
│  router.back()                                               │
│    → closes the modal                                        │
│    → returns to the gallery                                  │
│                                                              │
│  router.push('/')                                            │
│    → adds a history entry                                    │
│    → back button reopens the modal                           │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Parallel route convention@folder
Slot URL presenceNone
default.js requirementEvery slot, plus the children slot
Interception matchers(.), (..), (..)(..), (...)
Matcher basisRoute segments, not file paths
Modal closerouter.back()
Soft navigationIntercepted route
Hard navigationFull page route
Next.js 16 buildFails if a slot’s default.js is missing

Key takeaways:

  • A parallel slot is a named prop on the layout, not a URL segment. The @folder convention creates the slot, and the layout receives it alongside children. The slot streams independently, with its own loading.js and error.js, and it does not appear in the URL .
  • default.js is mandatory for every slot. A hard navigation cannot recover the slot’s active state, so Next.js renders the default.js fallback. Without the file, the slot produces a 404, and in Next.js 16 the build fails .
  • The interception matchers count route segments, not file-system directories. The @modal folder is a slot, so it is skipped when counting. A (.) inside @modal matches the same route level as the parent, even though the file path is one directory deeper .
  • The modal pattern combines a parallel slot with an intercepted route. The soft navigation renders the intercepted route inside the slot; the hard navigation bypasses the interception and renders the full page. The URL is the same in both cases, which makes the modal shareable and the page indexable .
  • The modal must close with router.back(). A push or a <Link> adds a new history entry, which breaks the back-button behavior. The back call returns to the gallery and closes the modal in one action .
  • The conditional slot rendering is a server-side decision. A layout can choose which slot to render based on the user’s role. The slots that are not rendered still execute on the server, so the authorization must be inside each slot’s page or in the data access layer, not only in the layout’s condition .

Remember: Parallel routes compose the layout, and intercepting routes change the context of a navigation. The @slot convention creates the composition, the default.js makes it survive a hard navigation, and the (.) matcher makes the modal appear on a soft navigation. The counting rule is the one that trips up most developers: the matcher follows route segments, and a slot folder is not a segment. The modal closes with router.back(), not with a navigation, because the back button is the behavior that the pattern is designed to preserve.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!