Skip to main content

Parallel routes and intercepting routes in Next.js

Parallel routes render multiple independent pages inside one layout using named @folder slots. Intercepting routes capture navigation to a child segment and render a modal or overlay instead, while the URL either stays on the parent or changes to the intercepted path depending on how the user got there.

Theory

TL;DR

  • Parallel routes use @folder syntax and are passed into a layout as separate React props
  • Each slot fetches data independently and has its own loading.tsx and error.tsx
  • Intercepting routes use (.)folder, (..)folder, or (...)folder to hijack navigation to a matched segment
  • Parallel routes are for multi-panel dashboards; intercepting routes are for modals that close with the browser back button
  • Together they power the Instagram-style photo modal: modal on client navigation, full page on direct URL visit

Quick Example

tsx
// app/dashboard/layout.tsx export default function DashboardLayout({ children, stats, notifications, }: { children: React.ReactNode; stats: React.ReactNode; // app/dashboard/@stats/page.tsx notifications: React.ReactNode; // app/dashboard/@notifications/page.tsx }) { return ( <div className="flex h-screen"> <aside className="w-80 border-r">{stats}</aside> <div className="flex-1 flex flex-col"> <header>{notifications}</header> <main>{children}</main> </div> </div> ); }

The stats and notifications slots fetch data in parallel. If stats throws, notifications and children still render fine, as long as each slot has its own error.tsx.

Key Difference

Parallel routes fill named slots from sibling folders at the same URL level. They render simultaneously, each as a full React Server Component subtree with independent data fetching. Intercepting routes do not add a new place in the layout. They take over rendering for a matched segment and show their component instead, which means the same URL can render two completely different UIs depending on whether you navigated there via a link or typed it directly.

When to Use

  • Multi-panel dashboard where panels call different APIs: parallel routes
  • Modal or drawer that opens over a page and should close with the back button: intercepting routes
  • Auth-conditional layout (dashboard vs login at the same URL): parallel routes with conditional rendering
  • Search overlay that appears as a modal via link but as a full page on direct visit: combine both

Skip parallel routes for simple two-column layouts you can handle with CSS. Skip intercepting routes for modals that do not need a shareable URL.

How Slots Work

Folders named with @ are slots. Next.js passes them as props to the nearest parent layout. The folder name becomes the prop name: @stats becomes the stats prop.

app/dashboard/ layout.tsx <- receives { children, stats, notifications } page.tsx <- becomes children @stats/ page.tsx <- becomes the stats prop loading.tsx <- only shows while stats is loading error.tsx <- only catches errors inside stats @notifications/ page.tsx <- becomes the notifications prop error.tsx

One thing that trips people up in production: if you forget default.tsx in a slot, Next.js throws a 404 when navigating to a URL where that slot has no matching segment. A default.tsx that returns null is enough to fix it.

Intercepting Routes: Convention and Segment Matching

The prefix controls which level the interception targets:

(.)photo - intercepts a segment at the same level (..)photo - intercepts one level up (..)(..)photo - two levels up (...)photo - intercepts from the app root

The canonical example: a /feed page has a @modal slot. Inside that slot, (.)photo/[id]/page.tsx intercepts navigation to /photo/[id] when it originates from within /feed. The URL changes to /photo/123, but Next.js renders the modal component, not the full photo page. Open /photo/123 directly and the interception does not apply.

Comparison Table

FeatureParallel RoutesIntercepting Routes
Syntax@slot/page.tsx(.)slot/page.tsx, (..)slot/page.tsx
RendersMultiple slots in layout simultaneouslyReplaces child segment rendering
URL behaviorEach slot corresponds to its folder pathURL changes but rendering is overridden
Data fetchingFully independent per slotShares parent context
Error isolationPer-slot error.tsxNeeds error.tsx in the intercept folder
Use caseDashboards, conditional auth screensPhoto modals, login overlays, search panels

How Next.js Handles This Internally

At build time, Next.js scans @folder names and generates slot props for layouts as part of the App Router file-convention system. During navigation, RSC fetches for each slot run in parallel with no waterfall. Intercepting routes work via segment matching priority: (.) matches the current path segment, (..) moves up one level, and the intercepted component suspends the child route render until it is dismissed.

Common Mistakes

Forgetting default.tsx in a slot

tsx
// app/dashboard/@stats/ has no default.tsx // Navigate to /dashboard/settings and Next.js 404s // because @stats has no segment matching that URL // Fix: add app/dashboard/@stats/default.tsx export default function StatsDefault() { return null; }

Missing per-slot loading.tsx

Without loading.tsx in each slot, a slow slot blocks the entire layout. Add @stats/loading.tsx and @notifications/loading.tsx separately. Each one becomes its own Suspense boundary.

Intercepting without the (.) prefix

app/settings/profile/page.tsx <- child route, changes URL, no interception app/settings/(.)profile/page.tsx <- intercepts /settings/profile, shows modal

Without (.), Next.js treats it as a regular nested route. The URL changes and the modal never appears.

No error handling in intercepting routes

tsx
// app/settings/(.)profile/page.tsx // Throws if userId is invalid. Without error.tsx here, // the whole layout crashes instead of just this overlay. const user = await fetchUser(searchParams.userId);

Add app/settings/(.)profile/error.tsx to catch errors locally. The parent layout stays intact.

Linking directly to a slot URL

<Link href="/dashboard/@stats"> treats @stats as a real URL segment, which breaks routing. Slots are layout props, not navigable paths.

Real-World Usage

  • Vercel dashboard: parallel slots for analytics overview and usage details, each hitting separate APIs
  • Linear: intercepting routes for the quick search modal over any project view
  • GitHub: parallel slots for repo readme and issues in a single layout
  • Instagram-style feed: the canonical photo modal from Next.js docs, combining a @modal slot with (.)photo/[id] interception
  • Supabase dashboard: intercepting modals for the SQL editor overlay on top of the tables view

Follow-up Questions

Q: How does data fetching in a parallel slot differ from a nested page?
A: Each slot runs its own fetch() as an independent RSC with no waterfall. A nested page layout blocks on the parent fetch before the child can render.

Q: What URL stays when an intercepting route opens a modal?
A: The URL changes to the intercepted path (e.g., /photo/123), but the layout renders the modal component. Navigating directly to that URL skips the interception and loads the full page.

Q: What happens if a parallel slot throws and has no error.tsx?
A: The error propagates up to the nearest error boundary. Without per-slot error.tsx, the whole layout errors out. With it, only that slot shows the error UI while everything else keeps rendering.

Q: Can you combine parallel and intercepting routes?
A: Yes, and this is the recommended pattern for photo modals. A @modal slot lives in the feed layout, and inside it (.)photo/[id] intercepts navigation to the photo page. The slot manages modal state; the interception handles segment matching.

Q: Senior: How does default.tsx affect streaming in parallel slots, and when does it break it?
A: default.tsx fills a slot when no segment matches the current URL. Next.js renders it eagerly without streaming. If default.tsx contains a heavy component or a slow import, it blocks the slot's stream even when the main content is already ready. Use default.tsx only for simple null returns or lightweight skeleton states.

Examples

Dashboard with Independent Panels

A Vercel-style dashboard where the stats sidebar loads and errors independently from the main chart.

tsx
// app/dashboard/layout.tsx export default function DashboardLayout({ children, stats, notifications, }: { children: React.ReactNode; stats: React.ReactNode; notifications: React.ReactNode; }) { return ( <div className="flex h-screen"> <aside className="w-80 border-r p-4">{stats}</aside> <div className="flex-1 flex flex-col"> <header className="border-b p-4">{notifications}</header> <main className="flex-1 p-6">{children}</main> </div> </div> ); } // app/dashboard/@stats/page.tsx export default async function Stats() { const data = await fetch("https://api.example.com/stats", { cache: "no-store", }).then((r) => r.json()); return ( <div> <p>Requests: {data.requests}</p> <p>Errors: {data.errors}</p> </div> ); } // app/dashboard/@stats/error.tsx "use client"; export default function StatsError() { return <p>Could not load stats.</p>; // Only this slot errors } // app/dashboard/@stats/loading.tsx export default function StatsLoading() { return <div className="animate-pulse h-20 bg-gray-100 rounded" />; }

Each slot fetches independently. If the stats API is down, the main chart and notifications still render.

Photo Modal with Intercepting Routes

The Instagram pattern: client navigation shows a modal, direct URL visit shows the full page.

tsx
// File structure: // app/feed/page.tsx -> /feed (photo grid) // app/feed/@modal/(.)photo/[id]/page.tsx -> intercepts /photo/[id] from /feed // app/feed/@modal/default.tsx -> null (no modal by default) // app/photo/[id]/page.tsx -> /photo/123 (full page, direct visit) // app/feed/layout.tsx export default function FeedLayout({ children, modal, }: { children: React.ReactNode; modal: React.ReactNode; }) { return ( <> {children} {modal} </> ); } // app/feed/@modal/(.)photo/[id]/page.tsx export default async function PhotoModal({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const photo = await getPhoto(id); return ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center"> <div className="bg-white rounded-lg p-4 max-w-2xl"> <img src={photo.url} alt={photo.title} className="w-full" /> <h2 className="mt-2 text-lg font-medium">{photo.title}</h2> </div> </div> ); } // app/photo/[id]/page.tsx 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"> <img src={photo.url} alt={photo.title} className="w-full" /> <h1 className="mt-4 text-2xl font-bold">{photo.title}</h1> <p className="mt-2 text-gray-600">{photo.description}</p> </div> ); } // app/feed/@modal/default.tsx export default function ModalDefault() { return null; }

Clicking a photo navigates to /photo/123. The (.)photo prefix intercepts that navigation inside the feed layout and renders the modal. Opening /photo/123 directly skips the interception and loads the full page.

Conditional Auth Layout

tsx
// app/layout.tsx import { getUser } from "@/lib/auth"; export default async function RootLayout({ children, dashboard, login, }: { children: React.ReactNode; dashboard: React.ReactNode; // app/@dashboard/page.tsx login: React.ReactNode; // app/@login/page.tsx }) { const user = await getUser(); // Each slot is a full component with its own data fetching. // The layout just picks which one renders. return ( <html lang="en"> <body>{user ? dashboard : login}</body> </html> ); }

Both @dashboard and @login are full page components with independent data and error boundaries. The layout decides which one renders based on the session check.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?