Next.js Parallel Routes and Intercepting Routes — Advanced Patterns

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Parallel routes and intercepting routes are two advanced App Router patterns that enable sophisticated UI behaviors without external modal libraries. Parallel routes render multiple segments simultaneously in the same layout — ideal for dashboards with independent panels. Intercepting routes let you show content in a modal overlay while keeping the URL shareable, exactly like Instagram or Pinterest photo modals.

Why This Matters

Before these patterns, building a photo gallery where clicking a photo shows it in a modal (but navigating to /photos/123 shows a full page) required complex state management, external modal libraries, and careful handling of browser history. The result was often broken back-button behavior and non-shareable URLs.

Intercepting routes solve this at the routing level: the same URL shows different UI depending on how you arrived at it. Navigate from the gallery — see the modal. Refresh or share the URL — see the full page. This is the routing pattern used by Vercel, Figma, and most large Next.js applications.

Parallel Routes with @slots

Parallel routes use folders prefixed with @ to define named slots in a layout. The layout receives each slot as a prop:

app/
├── layout.tsx          ← receives children, @analytics, @team
├── page.tsx            ← default children
├── @analytics/
│   ├── default.tsx     ← shown when analytics slot has no active route
│   └── page.tsx        ← /dashboard analytics panel
└── @team/
    ├── default.tsx
    └── page.tsx        ← /dashboard team panel
// app/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="dashboard-grid">
      <main>{children}</main>
      <aside className="analytics-panel">{analytics}</aside>
      <aside className="team-panel">{team}</aside>
    </div>
  )
}

Each slot is independently navigable and can have its own loading.tsx, error.tsx, and sub-routes.

default.tsx for Unmatched Slots

When navigating to a route that does not have a match in a slot, Next.js renders default.tsx. Without it, the slot shows a 404:

// app/@analytics/default.tsx
export default function AnalyticsDefault() {
  return <div className="p-4 text-gray-500">Select a date range to view analytics.</div>
}

Intercepting Routes Convention

Intercepting routes use (.) prefixes that mirror relative path traversal:

PrefixIntercepts
(.)segmentSame level
(..)segmentOne level up
(..)(..)segmentTwo levels up
(...)segmentFrom root

The canonical use case: a photo feed where clicking opens a modal, but the URL is shareable:

app/
├── layout.tsx
├── page.tsx                    ← photo feed
├── photos/
│   └── [id]/
│       └── page.tsx            ← full photo page (/photos/123)
└── @modal/
    ├── default.tsx             ← null (no modal by default)
    └── (.)photos/
        └── [id]/
            └── page.tsx        ← intercepted: shows modal when navigating from feed
// app/@modal/default.tsx
export default function ModalDefault() {
  return null // No modal shown by default
}
// app/@modal/(.)photos/[id]/page.tsx — intercepted modal route
import { Modal } from '@/components/Modal'
import { getPhoto } from '@/lib/photos'
 
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.description} className="max-h-screen object-contain" />
      <p className="p-4">{photo.description}</p>
    </Modal>
  )
}
// app/photos/[id]/page.tsx — full page (shown on direct navigation or refresh)
import { getPhoto } from '@/lib/photos'
 
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)
 
  return (
    <div className="max-w-4xl mx-auto py-12">
      <img src={photo.url} alt={photo.description} className="w-full rounded-lg" />
      <h1 className="text-2xl font-bold mt-6">{photo.title}</h1>
      <p className="text-gray-600 mt-2">{photo.description}</p>
    </div>
  )
}

The Modal component needs a way to close itself — use useRouter().back():

// components/Modal.tsx
'use client'
 
import { useRouter } from 'next/navigation'
import { ReactNode, useEffect } from 'react'
 
export function Modal({ children }: { children: ReactNode }) {
  const router = useRouter()
 
  function close() {
    router.back()
  }
 
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') close()
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [])
 
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="absolute inset-0 bg-black/75" onClick={close} />
      <div className="relative z-10 max-w-3xl w-full mx-4 bg-white rounded-xl overflow-hidden">
        <button
          onClick={close}
          className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-8 h-8"
        >
          X
        </button>
        {children}
      </div>
    </div>
  )
}

Parallel Routes with Independent Loading

Each slot can have its own loading.tsx for independent streaming:

app/
├── @analytics/
│   ├── loading.tsx    ← shown while analytics data loads
│   └── page.tsx
└── @team/
    ├── loading.tsx    ← shown while team data loads
    └── page.tsx
// app/@analytics/loading.tsx
export default function AnalyticsLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded mb-4" />
      <div className="h-32 bg-gray-200 rounded" />
    </div>
  )
}

The analytics and team panels load independently — if analytics is slow, the team panel still shows immediately.

Conditional Slot Rendering

Render different content in a slot based on authentication:

// app/layout.tsx
import { auth } from '@/lib/auth'
 
export default async function Layout({
  children,
  authModal,
  dashboard,
}: {
  children: React.ReactNode
  authModal: React.ReactNode
  dashboard: React.ReactNode
}) {
  const session = await auth()
 
  return (
    <div>
      {children}
      {session ? dashboard : authModal}
    </div>
  )
}

Common Mistakes

  • Forgetting default.tsx in parallel route slots — causes 404 on hard refresh
  • Using (..) when (.) is needed — intercepting from the wrong directory level
  • Not using router.back() to close modals — the intercepted route stays shown
  • Putting the @modal slot inside a nested route instead of at the right directory level
  • Expecting intercepted routes to work without client-side navigation — they only intercept <Link> clicks

Best Practices

  • Always create default.tsx for every parallel route slot — prevents 404 on hard refresh
  • Use intercepting routes for photo galleries, comment threads, and detail panels
  • Add keyboard navigation (Escape to close) and backdrop click handling to modal components
  • Use loading.tsx per slot for independent loading states in dashboard layouts
  • Test by both clicking a <Link> (interception should trigger) and hard-refreshing (full page should show)

Key Takeaways

  • Parallel routes use @slotName folders and are received as props by the parent layout
  • default.tsx is required in parallel route slots to render something when the slot is unmatched
  • Intercepting routes use (.), (..), or (...) prefix to intercept navigation at different levels
  • (.)segment intercepts at the same level; (..)segment intercepts from one level up
  • Intercepted routes only trigger on client-side navigation via <Link> — direct URL access shows the full page
  • The classic use case is photo/content modals where the URL is shareable but a modal shows in-context
  • router.back() is the standard way to close an intercepted modal
  • Each parallel route slot supports its own loading.tsx, error.tsx, and not-found.tsx

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro