Chapter 15

Intercepting Routes and Parallel Routes: Advanced UI Patterns

The Core Problem These Two Patterns Solve

Modern web applications have a classic interaction challenge: clicking an image in a gallery should open a modal showing the full image while keeping the background feed intact. But if the user refreshes the page or pastes the URL directly, the image should appear full-screen. Instagram, Pinterest, and GitHub's file preview all use this pattern.

What seems like a simple requirement actually contains three conflicting constraints: the URL must be shareable (each image has a unique URL), the content must be directly accessible (the URL opens a complete page), and navigating from the list must show a modal rather than a full page transition. Traditional routing either sacrifices URL shareability or requires complex global state management to simulate the behavior. Next.js's combination of intercepting routes and parallel routes solves this cleanly at the routing layer — no client-side state required.

Parallel Routes: The @slot Syntax

Parallel routes allow multiple pages to be rendered simultaneously within the same layout. The syntax is a @ prefix on a folder name; such folders are called slots.

app/
  layout.tsx        ← Receives all slots as props
  page.tsx
  @modal/
    default.tsx     ← Rendered when no route matches this slot
    (.)photos/
      [id]/
        page.tsx    ← Intercepting route: shows the modal
  photos/
    [id]/
      page.tsx      ← Direct access: shows the full page

The root layout receives each slot as an independent prop:

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

@modal is not part of the URL — it is only a channel through which the layout receives content. Users will never see @modal appear in the address bar.

default.tsx: The Fallback for Unmatched Slots

A critical detail of parallel routes is the default.tsx file. When navigation occurs and a slot has no matching content, Next.js looks for default.tsx inside that slot and renders it. If default.tsx is also absent, Next.js renders a 404.

For the modal scenario, we want the @modal slot to display nothing most of the time:

// app/@modal/default.tsx
export default function ModalDefault() {
  return null  // Render nothing
}

This makes @modal invisible during regular page access and only injects content when an intercepting route is activated.

Intercepting Routes: (.) (..) (...) Syntax

Intercepting routes use special bracket prefixes to declare which level they intercept from:

Syntax Meaning
(.)segment Intercept a route at the same level
(..)segment Intercept a route one level up
(..)(..)segment Intercept a route two levels up
(...)segment Intercept from the root directory

"Intercepting" means: when the user arrives at this route via client-side navigation (clicking a link), the content defined in the intercepting route is shown. When the user accesses the URL directly (refresh, paste into address bar), the normal route content is shown instead.

In our example, (.) in @modal/(.)photos/[id]/page.tsx means it intercepts the /photos/[id] route at the same level as itself (both under the app root):

app/
  @modal/
    (.)photos/       ← (.) refers to photos at the app/ root level
      [id]/
        page.tsx
  photos/            ← The original route being intercepted
    [id]/
      page.tsx

Building an Instagram-Style Photo Modal: Complete Implementation

The Photo Feed

// app/photos/page.tsx
import Link from 'next/link'
import { getPhotos } from '@/lib/photos'

export default async function PhotosPage() {
  const photos = await getPhotos()

  return (
    <div className="grid grid-cols-3 gap-4 p-8">
      {photos.map((photo) => (
        <Link key={photo.id} href={`/photos/${photo.id}`}>
          <img
            src={photo.thumbnailUrl}
            alt={photo.title}
            className="w-full aspect-square object-cover rounded-lg cursor-pointer
                       hover:opacity-80 transition-opacity"
          />
        </Link>
      ))}
    </div>
  )
}

The Full Photo Page (Shown on Direct URL Access)

// app/photos/[id]/page.tsx
import { getPhoto } from '@/lib/photos'
import { notFound } from 'next/navigation'

interface Props {
  params: Promise<{ id: string }>
}

export default async function PhotoPage({ params }: Props) {
  const { id } = await params
  const photo = await getPhoto(id)
  if (!photo) notFound()

  return (
    <div className="min-h-screen flex flex-col items-center justify-center bg-black">
      <img
        src={photo.url}
        alt={photo.title}
        className="max-w-4xl max-h-screen object-contain"
      />
      <h1 className="text-white mt-4 text-xl">{photo.title}</h1>
    </div>
  )
}

The Intercepting Route Modal (Shown During Client Navigation)

// app/@modal/(.)photos/[id]/page.tsx
import { getPhoto } from '@/lib/photos'
import { notFound } from 'next/navigation'
import { PhotoModal } from '@/components/PhotoModal'

interface Props {
  params: Promise<{ id: string }>
}

export default async function PhotoModalPage({ params }: Props) {
  const { id } = await params
  const photo = await getPhoto(id)
  if (!photo) notFound()

  return <PhotoModal photo={photo} />
}

The Modal Component (Client Component Handling Dismiss Logic)

// components/PhotoModal.tsx
'use client'

import { useRouter } from 'next/navigation'
import { useEffect, useCallback } from 'react'
import type { Photo } from '@/lib/photos'

interface PhotoModalProps {
  photo: Photo
}

export function PhotoModal({ photo }: PhotoModalProps) {
  const router = useRouter()

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

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

  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/80 z-40 cursor-pointer"
        onClick={handleClose}
      />
      {/* Modal content */}
      <div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
        <div
          className="relative bg-white rounded-xl overflow-hidden max-w-3xl w-full
                     mx-4 pointer-events-auto"
          onClick={(e) => e.stopPropagation()}
        >
          <button
            onClick={handleClose}
            className="absolute top-4 right-4 text-gray-500 hover:text-gray-900 z-10"
            aria-label="Close"
          >
            ✕
          </button>
          <img
            src={photo.url}
            alt={photo.title}
            className="w-full object-cover"
          />
          <div className="p-6">
            <h2 className="text-2xl font-bold">{photo.title}</h2>
            <p className="text-gray-600 mt-2">{photo.description}</p>
          </div>
        </div>
      </div>
    </>
  )
}

The reason for calling router.back() rather than router.push('/') when closing: we want to return to whatever page the user was on before opening the modal, not hardcode a redirect to the home page. This way, no matter which page the user opened the modal from, closing it returns them to the right place.

Parallel Routes for Dashboard Layouts

Beyond modals, parallel routes excel at dashboard scenarios where multiple independent content areas need to display simultaneously. Each slot can independently handle loading, error states, and Suspense:

app/
  dashboard/
    layout.tsx
    page.tsx
    @analytics/
      page.tsx      ← Analytics data
      loading.tsx   ← Independent loading state
    @revenue/
      page.tsx      ← Revenue data
      loading.tsx
    @notifications/
      page.tsx      ← Notification list
      error.tsx     ← Independent error boundary
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  revenue,
  notifications,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  revenue: React.ReactNode
  notifications: React.ReactNode
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow px-8 py-4">
        <h1 className="text-2xl font-bold">Dashboard</h1>
      </header>
      <main className="p-8 grid grid-cols-12 gap-6">
        <div className="col-span-8">{children}</div>
        <aside className="col-span-4 space-y-6">
          {analytics}
          {revenue}
          {notifications}
        </aside>
      </main>
    </div>
  )
}

Each slot has its own loading.tsx and error.tsx. This means that when analytics data loads slowly, revenue and notification content are not blocked — users can interact with what has already loaded. This is a substantially better experience than wrapping the entire page in a single Suspense boundary.

Conditional Rendering: Tab-Based Content Switching

Parallel routes can also power URL-driven conditional content rendering:

app/
  dashboard/
    @tab/
      overview/
        page.tsx
      settings/
        page.tsx
      default.tsx   ← Shows overview content by default

Determining which tab is active by checking the current route requires zero client-side state:

// app/dashboard/layout.tsx
import Link from 'next/link'

export default function DashboardLayout({
  children,
  tab,
}: {
  children: React.ReactNode
  tab: React.ReactNode
}) {
  return (
    <div>
      <nav className="flex gap-4 border-b pb-2 mb-6">
        <Link href="/dashboard/overview">Overview</Link>
        <Link href="/dashboard/settings">Settings</Link>
      </nav>
      {tab || children}
    </div>
  )
}

Understanding the navigation behavior of intercepting and parallel routes is essential for avoiding bugs.

Soft navigation (client-side navigation): When the user clicks a <Link> or calls router.push(), Next.js only loads what has changed. Other parallel route slots retain their current state. Intercepting routes are active.

Hard navigation (full page refresh / direct URL access): Next.js re-fetches the entire page from the server. All slots re-render. Intercepting routes are not active — the original route content is shown instead.

This distinction explains why the photo modal disappears after a refresh. Refreshing triggers a hard navigation; the @modal slot has no matching route, so it renders default.tsx (which returns null), while photos/[id]/page.tsx renders the full page directly. This is exactly the intended behavior.

Integration with loading.tsx and error.tsx

Every parallel route slot can have its own loading.tsx and error.tsx, which is the foundation for fine-grained error handling:

// app/@modal/(.)photos/[id]/loading.tsx
export default function ModalLoading() {
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="bg-white rounded-xl p-8 shadow-2xl">
        <div className="animate-spin w-8 h-8 border-4 border-blue-500
                       border-t-transparent rounded-full mx-auto" />
      </div>
    </div>
  )
}
// app/dashboard/@analytics/error.tsx
'use client'

export default function AnalyticsError({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div className="bg-red-50 border border-red-200 rounded-lg p-4">
      <p className="text-red-600">Failed to load analytics data</p>
      <button
        onClick={reset}
        className="mt-2 text-sm text-red-500 underline"
      >
        Try again
      </button>
    </div>
  )
}

Common Pitfalls and Debugging Tips

Pitfall 1: Miscounting the (.) level. The level for (.) is computed relative to where the slot folder sits, not relative to the app root. The (.) in @modal/(.)photos points to the photos folder that is a sibling of @modal — which is app/photos.

Pitfall 2: Forgetting default.tsx. If a slot has no default.tsx and no route matches it, the entire page becomes a 404. Always provide a default.tsx for every slot.

Pitfall 3: The underlying list not refreshing after data mutations inside the modal. After submitting data with a Server Action, call router.refresh() to make Next.js re-fetch the current page's server data without triggering a full page reload:

'use client'
import { useRouter } from 'next/navigation'

export function DeletePhotoButton({ photoId }: { photoId: string }) {
  const router = useRouter()

  async function handleDelete() {
    await fetch(`/api/photos/${photoId}`, { method: 'DELETE' })
    router.back()    // Close the modal
    router.refresh() // Refresh the underlying list data
  }

  return <button onClick={handleDelete}>Delete</button>
}

Summary

Intercepting routes and parallel routes are the most expressive routing features in the App Router. They elevate UI patterns that previously required extensive client-side state management — modals, dashboard sections, conditional content — to the routing layer. The core design philosophy is that the URL is the source of truth and UI state is determined by route structure, not React state. Using these features correctly requires a clear understanding of the difference between soft and hard navigation, and the fallback role that default.tsx plays when a slot has no match.

Rate this chapter
4.8  / 5  (17 ratings)

💬 Comments