🪟 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
// 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>
)
}
// 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
// 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} />
}
// app/dashboard/@analytics/loading.tsx
export default function Loading() {
return <div className="h-64 animate-pulse rounded bg-slate-200" />
}
// 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>
)
}
// 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.
// app/dashboard/@analytics/default.tsx
export default function Default() {
return null
}
⚠️ Changed in Next.js 16
default.jsis 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
nullwhen 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:
// 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>
)
}
// 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:
| Pattern | Intercepts |
|---|---|
(.)folder | the same level |
(..)folder | one level up |
(..)(..)folder | two levels up |
(...)folder | from 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
// app/photos/layout.tsx
export default function PhotosLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
// app/photos/layout.js
export default function PhotosLayout({ children, modal }) {
return (
<>
{children}
{modal}
</>
)
}
// 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>
)
}
// 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>
)
}
// 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>
)
}
// 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>
)
}
// 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:
// 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>
)
}
// 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
// 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>
)
}
// 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
{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/42into 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:
/docsshows a list/docs/:idshows a document full-page- Clicking a doc from the list opens it in a side panel, URL changes to
/docs/:id - Refresh on
/docs/:idshows 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
@foldercreates a slot passed to the parent layout as a prop, letting one URL render several independent pages with their own loading and error states.default.tsxis mandatory in Next.js 16. Create it the moment you create a slot —nullfor optional slots,notFound()when a missing match means an invalid URL.(.),(..),(...)intercept routes by segment count, not folder count, and route groups don't count as segments.- 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. - These are specialist tools. Most apps don't need them. Use
<Suspense>for streaming anduseStatefor 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.