Dev Logs
/Next.js/ Chapter 7: Parallel & Intercepting Routes
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
    • Plain English Explanation
    • Parallel routes
    • Why bother? Independent loading and errors
    • default.tsx — and why it's now mandatory
    • Conditional slots
    • Intercepting routes
    • What "intercepting" means
    • Building it
    • What you get for free
    • Parallel routes without interception
    • Common Pitfalls
    • . Missing default.tsx
    • . Miscounting interception levels
    • . Forgetting that the full page must still exist
    • . Naming a slot @children
    • . Expecting slots to control access
    • . Reaching for this when a component would do
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Count the levels
    • Problem 2: Debug the build
    • Problem 3: Trace the render
    • Problem 4: Build it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🪟 Chapter 7: Parallel & Intercepting Routes

Rendering two pages at once, and the trick behind photo-gallery modals that survive a page refresh.

📖 Plain English Explanation

Everything so far has assumed one URL renders one page. Two features break that assumption.

Parallel routes let a single layout render several independent pages side by side. Think of a dashboard where the analytics panel, the recent-activity feed, and the team list each load, fail, and stream independently. If the analytics query is slow, the activity feed shouldn't wait for it. If the team list errors, the rest of the dashboard shouldn't vanish.

Intercepting routes let a URL render different UI depending on how you arrived. Click a photo in a gallery and you get a modal over the grid. Paste that same URL into a fresh tab and you get a full page. Same URL, two presentations — chosen by context.

Together they produce the modal pattern that every social app uses and that used to require a pile of client-side state to fake.

These are the most advanced routing features in Next.js. You will not need them in most apps. When you do need them, nothing else comes close.

🎰 Parallel routes

A folder prefixed with @ is a slot. It doesn't create a URL segment. Instead, it's passed to the parent layout as a prop named after the folder.

app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx
│   └── default.tsx
└── @activity/
    ├── page.tsx
    └── default.tsx
tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  activity,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  activity: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-3 gap-6">
      <div className="col-span-3">{children}</div>
      <div className="col-span-2">{analytics}</div>
      <div>{activity}</div>
    </div>
  )
}
jsx
// app/dashboard/layout.js
export default function DashboardLayout({ children, analytics, activity }) {
  return (
    <div className="grid grid-cols-3 gap-6">
      <div className="col-span-3">{children}</div>
      <div className="col-span-2">{analytics}</div>
      <div>{activity}</div>
    </div>
  )
}

children is itself an implicit slot — it's just the unnamed one.

Why bother? Independent loading and errors

Each slot gets its own loading.tsx and error.tsx. That's the real payoff:

app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx
│   ├── loading.tsx      ← only this panel shows a skeleton
│   ├── error.tsx        ← only this panel shows an error
│   └── default.tsx
└── @activity/
    ├── page.tsx
    ├── loading.tsx
    ├── error.tsx
    └── default.tsx
tsx
// app/dashboard/@analytics/page.tsx
import { getAnalytics } from '@/lib/analytics'

export default async function Analytics() {
  const data = await getAnalytics()      // slow — 2 seconds
  return <Chart data={data} />
}
tsx
// app/dashboard/@analytics/loading.tsx
export default function Loading() {
  return <div className="h-64 animate-pulse rounded bg-slate-200" />
}
tsx
// app/dashboard/@analytics/error.tsx
'use client'

export default function Error({ retry }: { error: Error; retry: () => void }) {
  return (
    <div className="rounded border border-red-200 p-4">
      <p>Analytics failed to load.</p>
      <button onClick={() => retry()}>Retry</button>
    </div>
  )
}
jsx
// app/dashboard/@analytics/error.js
'use client'

export default function Error({ retry }) {
  return (
    <div className="rounded border border-red-200 p-4">
      <p>Analytics failed to load.</p>
      <button onClick={() => retry()}>Retry</button>
    </div>
  )
}

Now a failing analytics query shows a small retry box while the activity feed and the main content stay perfectly usable. Without parallel routes you'd need to hand-roll this with Suspense and error boundaries, and you'd lose the routing integration.

default.tsx — and why it's now mandatory

Slots have a subtle problem. Consider:

app/dashboard/
├── page.tsx                    →  /dashboard
├── @analytics/page.tsx
└── @activity/
    ├── page.tsx
    └── detail/page.tsx         →  /dashboard/detail

Navigate to /dashboard/detail. The @activity slot has a match. But what should @analytics render? There's no @analytics/detail/page.tsx.

On a soft navigation (clicking a <Link>), Next.js keeps whatever the slot was showing. But on a hard navigation (page refresh, or pasting the URL), there's no previous state to keep — and Next.js doesn't know what to render.

default.tsx answers that question.

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

⚠️ Changed in Next.js 16

default.js is now required for every parallel route slot. Builds fail without it.

Previously Next.js would silently render nothing. Now you must be explicit:

tsx
// app/dashboard/@analytics/default.tsx — render nothing
export default function Default() {
  return null
}
tsx
// app/dashboard/@analytics/default.tsx — or 404 the whole route
import { notFound } from 'next/navigation'

export default function Default() {
  notFound()
}

Pick null when the slot is genuinely optional, notFound() when a missing match means the URL is invalid.

The simplest reliable habit: every time you create an @slot folder, immediately create default.tsx in it.

Conditional slots

Because slots are just props, a layout can choose between them:

tsx
// app/dashboard/layout.tsx
import { getRole } from '@/lib/auth'

export default async function Layout({
  children,
  admin,
  user,
}: {
  children: React.ReactNode
  admin: React.ReactNode
  user: React.ReactNode
}) {
  const role = await getRole()

  return (
    <div>
      {children}
      {role === 'admin' ? admin : user}
    </div>
  )
}
jsx
// app/dashboard/layout.js
import { getRole } from '@/lib/auth'

export default async function Layout({ children, admin, user }) {
  const role = await getRole()

  return (
    <div>
      {children}
      {role === 'admin' ? admin : user}
    </div>
  )
}

Two entirely different dashboards at one URL, chosen on the server. Note that both slots still render server-side — this controls display, not authorization.

🎭 Intercepting routes

The syntax borrows from relative paths, but the numbers count route segments, not filesystem folders:

PatternIntercepts
(.)folderthe same level
(..)folderone level up
(..)(..)foldertwo levels up
(...)folderfrom the app root

Route groups (name) don't count as segments when you're counting levels.

What "intercepting" means

You have a photo gallery at /photos, and photo detail pages at /photos/123.

You want: clicking a thumbnail opens a modal over the grid, but the URL still becomes /photos/123 so it's shareable. Loading /photos/123 fresh gives a full page.

app/
├── photos/
│   ├── page.tsx                  →  /photos (the grid)
│   ├── [id]/page.tsx             →  /photos/123 (full page)
│   └── @modal/
│       ├── default.tsx
│       └── (.)[id]/page.tsx      →  intercepts /photos/123 as a modal
└── layout.tsx

Next.js routes it like this:

Soft nav (click a Link from /photos)
  →  @modal/(.)[id]/page.tsx renders    →  modal over the grid

Hard nav (refresh, paste URL, external link)
  →  photos/[id]/page.tsx renders       →  full page

Same URL, two behaviors, no client-side state involved.

Building it

tsx
// app/photos/layout.tsx
export default function PhotosLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}
jsx
// app/photos/layout.js
export default function PhotosLayout({ children, modal }) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}
tsx
// app/photos/page.tsx  — the grid
import Link from 'next/link'
import Image from 'next/image'
import { getPhotos } from '@/lib/photos'

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

  return (
    <div className="grid grid-cols-4 gap-4">
      {photos.map((photo) => (
        <Link key={photo.id} href={`/photos/${photo.id}`}>
          <Image src={photo.thumb} alt={photo.alt} width={300} height={300} />
        </Link>
      ))}
    </div>
  )
}
tsx
// app/photos/[id]/page.tsx  — the full page
import Image from 'next/image'
import { getPhoto } from '@/lib/photos'

export default async function Page(props: PageProps<'/photos/[id]'>) {
  const { id } = await props.params
  const photo = await getPhoto(id)

  return (
    <article className="mx-auto max-w-3xl">
      <Image src={photo.full} alt={photo.alt} width={1200} height={800} />
      <h1>{photo.title}</h1>
      <p>{photo.description}</p>
    </article>
  )
}
tsx
// app/photos/@modal/(.)[id]/page.tsx  — the modal
import Image from 'next/image'
import { getPhoto } from '@/lib/photos'
import { Modal } from '@/components/Modal'

export default async function PhotoModal(props: PageProps<'/photos/[id]'>) {
  const { id } = await props.params
  const photo = await getPhoto(id)

  return (
    <Modal>
      <Image src={photo.full} alt={photo.alt} width={1200} height={800} />
      <h2>{photo.title}</h2>
    </Modal>
  )
}
jsx
// app/photos/@modal/(.)[id]/page.js
import Image from 'next/image'
import { getPhoto } from '@/lib/photos'
import { Modal } from '@/components/Modal'

export default async function PhotoModal({ params }) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <Modal>
      <Image src={photo.full} alt={photo.alt} width={1200} height={800} />
      <h2>{photo.title}</h2>
    </Modal>
  )
}
tsx
// app/photos/@modal/default.tsx  — nothing when no photo is selected
export default function Default() {
  return null
}

The Modal component handles dismissal by navigating back:

tsx
// components/Modal.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

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

  useEffect(() => {
    if (!dialogRef.current?.open) dialogRef.current?.showModal()
  }, [])

  function close() {
    router.back()      // pops the history entry → modal unmounts
  }

  return (
    <dialog
      ref={dialogRef}
      onClose={close}
      onClick={(e) => e.target === dialogRef.current && close()}
      className="rounded-lg p-6 backdrop:bg-black/50"
    >
      {children}
    </dialog>
  )
}
jsx
// components/Modal.js
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

export function Modal({ children }) {
  const router = useRouter()
  const dialogRef = useRef(null)

  useEffect(() => {
    if (!dialogRef.current?.open) dialogRef.current?.showModal()
  }, [])

  function close() {
    router.back()
  }

  return (
    <dialog
      ref={dialogRef}
      onClose={close}
      onClick={(e) => e.target === dialogRef.current && close()}
      className="rounded-lg p-6 backdrop:bg-black/50"
    >
      {children}
    </dialog>
  )
}

Using the native <dialog> element gets you focus trapping, Escape-to-close, and aria-modal for free. Hand-rolled <div> modals almost always get accessibility wrong.

What you get for free

  • ✅ The URL is real and shareable
  • ✅ Browser back closes the modal
  • ✅ Refresh gives the full page, not a broken modal
  • ✅ Modal content is server-rendered — no client fetch, no loading spinner
  • ✅ SEO crawlers see the full page

🧩 Parallel routes without interception

Slots are useful on their own for split views:

app/inbox/
├── layout.tsx
├── @list/
│   ├── page.tsx
│   └── default.tsx
└── @detail/
    ├── page.tsx           ← "select a message"
    ├── [id]/page.tsx      ← a specific message
    └── default.tsx
tsx
// app/inbox/layout.tsx
export default function InboxLayout({
  list,
  detail,
}: {
  list: React.ReactNode
  detail: React.ReactNode
}) {
  return (
    <div className="flex h-screen">
      <div className="w-80 overflow-y-auto border-r">{list}</div>
      <div className="flex-1 overflow-y-auto">{detail}</div>
    </div>
  )
}
jsx
// app/inbox/layout.js
export default function InboxLayout({ list, detail }) {
  return (
    <div className="flex h-screen">
      <div className="w-80 overflow-y-auto border-r">{list}</div>
      <div className="flex-1 overflow-y-auto">{detail}</div>
    </div>
  )
}

Clicking a message updates only the @detail slot. The list keeps its scroll position, because it never re-rendered.

⚠️ Common Pitfalls

1. Missing default.tsx

The most common failure since Next.js 16:

Error: No default component was found for the parallel route slot "@modal"

Fix: add default.tsx returning null (or calling notFound()) to every slot. Do it the moment you create the slot folder.

2. Miscounting interception levels

app/
└── feed/
    ├── page.tsx
    └── @modal/
        └── (.)photo/[id]/page.tsx    ← intercepts /feed/photo/:id

To intercept /photo/:id (one level up from feed), you'd need (..)photo/[id].

Remember: the numbers count route segments, not directories. Route groups (name) don't count.

3. Forgetting that the full page must still exist

app/photos/
├── page.tsx
└── @modal/(.)[id]/page.tsx     ← only the modal

Refreshing /photos/123 gives a 404. The interception only handles soft navigation; the real route must exist too. Fix: create app/photos/[id]/page.tsx.

4. Naming a slot @children

children is the implicit slot. Naming a folder @children conflicts with it. Pick any other name.

5. Expecting slots to control access

tsx
{role === 'admin' ? admin : user}

Both slots render on the server before the layout picks one. Data fetched in the admin slot is fetched even for regular users — it just isn't displayed. Fix: authorize inside each slot's data access, not in the layout's conditional.

6. Reaching for this when a component would do

If your "modal" doesn't need a URL, doesn't need to be shareable, and doesn't need to survive refresh — use useState and a plain dialog component. Intercepting routes solve a specific problem (URL-addressable overlays). They are not the default way to build modals.

🎯 When & Why to Use

Parallel routes when:
  ✅ Independent sections load at different speeds and shouldn't block each other
  ✅ Each section needs its own loading and error UI
  ✅ Split-pane layouts (list + detail, inbox, file browser)
  ✅ One URL, different panels depending on role or feature flag

Intercepting routes when:
  ✅ A modal needs a shareable URL
  ✅ The same content is a modal from inside the app and a page from outside
  ✅ Back button should close the overlay

Skip both when:
  ❌ A plain <Suspense> boundary gives you what you need
  ❌ The modal is ephemeral (confirm dialog, dropdown, toast)
  ❌ You'd be adding routing complexity for a component-level problem

Honest guidance: most apps never need these. Learn to recognize the shape of the problem, then reach for the tool.

🏋️ Mini Practice Problems

Problem 1: Count the levels

Given app/dashboard/settings/@modal/, which pattern intercepts each route?

  • A. /dashboard/settings/new
  • B. /dashboard/new
  • C. /new

Problem 2: Debug the build

Error: No default component was found for the parallel route slot "@team"

The tree is:

app/org/
├── layout.tsx
├── page.tsx
├── @team/page.tsx
└── @billing/
    ├── page.tsx
    └── default.tsx

What's missing, and what are the two valid contents for the fix?

Problem 3: Trace the render

With the photo gallery from this chapter, which component renders in each case?

  • A. User is on /photos, clicks a thumbnail
  • B. User pastes /photos/42 into a new tab
  • C. User is on /photos/42 (from A) and hits refresh
  • D. User is on /photos/42 (from A) and hits back

Problem 4: Build it

Design the file tree for a documents app where:

  • /docs shows a list
  • /docs/:id shows a document full-page
  • Clicking a doc from the list opens it in a side panel, URL changes to /docs/:id
  • Refresh on /docs/:id shows the full page
  • The list panel must keep its scroll position while browsing documents

💼 Interview Notes

Common Questions

Q: What are parallel routes? Named slots (@folder) passed to a layout as props, letting one URL render several independent pages simultaneously. Each slot has its own loading state, error boundary, and sub-routing.

Q: Why does every slot need a default.tsx in Next.js 16? On a hard navigation there's no prior state, so Next.js can't infer what an unmatched slot should render. Previously it silently rendered nothing; now it errors at build time and requires you to be explicit — null or notFound().

Q: What are intercepting routes and what problem do they solve? They render different UI for the same URL depending on how you navigated to it. The canonical use is a modal: soft navigation shows an overlay, a direct visit or refresh shows the full page — with one shareable URL and working browser back.

Q: How do you build a shareable photo modal? A @modal parallel slot in the gallery layout, containing an intercepting route (.)[id]/page.tsx, plus the real [id]/page.tsx for direct visits, plus default.tsx returning null. The modal closes with router.back().

Q: Do the interception numbers count folders or segments? Route segments. Route groups (name) don't add a segment, so they don't count.

Q: Can you use a conditional slot for authorization? No. Both slots render on the server before the layout chooses. It controls what's displayed, not what's fetched. Authorize at the data layer.

🏢 Asked at Companies

  • Vercel: "Build Instagram's photo modal. It must be shareable, survive refresh, and close on back."
  • Meta: "A dashboard has three panels, one of which is slow and occasionally errors. How do you keep the other two usable?"
  • Linear: "Design a list-detail split view where the list never re-renders when you select an item."
  • Notion: "When is a parallel route the wrong tool, and what would you use instead?"

📊 Visual Memory Aid

              PARALLEL ROUTES

  app/dashboard/
    layout.tsx  ({ children, analytics, activity })
    page.tsx         ─────►  children
    @analytics/      ─────►  analytics   ┐ each with own
    @activity/       ─────►  activity    ┘ loading + error

  ┌────────────────────────────────────┐
  │  children (main content)           │
  ├──────────────────────┬─────────────┤
  │  analytics  ⏳       │  activity ✅ │  ← independent
  └──────────────────────┴─────────────┘


              INTERCEPTING ROUTES

  (.)folder       →  same level
  (..)folder      →  one segment up
  (..)(..)folder  →  two segments up
  (...)folder     →  from app root


              THE MODAL PATTERN

   /photos ──click──►  /photos/42
                        │
                        └─► @modal/(.)[id]/page.tsx
                            ┌─────────────────┐
                            │   [ modal ]     │  grid still behind
                            └─────────────────┘

   /photos/42 ──refresh──►  photos/[id]/page.tsx
                            ┌─────────────────┐
                            │   full page     │
                            └─────────────────┘

   Same URL. Different render. Zero client state.

🎯 Key Takeaways

  1. @folder creates a slot passed to the parent layout as a prop, letting one URL render several independent pages with their own loading and error states.
  2. default.tsx is mandatory in Next.js 16. Create it the moment you create a slot — null for optional slots, notFound() when a missing match means an invalid URL.
  3. (.), (..), (...) intercept routes by segment count, not folder count, and route groups don't count as segments.
  4. The modal pattern needs three pieces: the intercepting route, the real full-page route, and the default.tsx. Skip the real route and refresh gives a 404.
  5. These are specialist tools. Most apps don't need them. Use <Suspense> for streaming and useState for ephemeral dialogs; reach for slots and interception only when the URL itself must carry the state.

Next Chapter: Loading, Suspense & Streaming →

Practice: Build the photo gallery from this chapter end to end. Verify all four behaviors: click opens a modal, back closes it, refresh shows the full page, and pasting the URL in a new tab shows the full page.


PreviousChapter 6: Route Groups & OrganizationNextChapter 8: Loading, Suspense & Streaming

Open source, free forever. Built by iammhador.

Contribute on GitHub