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>
)
}
Navigation Behavior in Detail
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.