Next.js Parallel Routes and Intercepting Routes — Advanced Patterns
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:
| Prefix | Intercepts |
|---|---|
(.)segment | Same level |
(..)segment | One level up |
(..)(..)segment | Two levels up |
(...)segment | From root |
Modal Photo Gallery — Classic Example
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.tsxin 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
@modalslot 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.tsxfor 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.tsxper 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
@slotNamefolders and are received as props by the parent layout default.tsxis required in parallel route slots to render something when the slot is unmatched- Intercepting routes use
(.),(..), or(...)prefix to intercept navigation at different levels (.)segmentintercepts at the same level;(..)segmentintercepts 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, andnot-found.tsx
Advertisement