Parallel and Intercepting Routes
The idea in one sentence
Section titled “The idea in one sentence”Parallel routes let one layout render several independent slots at once, and intercepting routes let a route render inside the current layout instead of on its own page.
Parallel routes with named slots
Section titled “Parallel routes with named slots”A folder prefixed with @ like @team or @analytics is a named slot. The layout in the same folder receives each slot as a prop and can render them simultaneously. Slots are not URL segments — app/@analytics/views still resolves to the URL /views, not /@analytics/views — they only combine with a real page.tsx to form the page for a given route.
app/ dashboard/ layout.tsx page.tsx @team/ page.tsx @analytics/ page.tsxexport default function Layout({ children, team, analytics,}: { children: React.ReactNode team: React.ReactNode analytics: React.ReactNode}) { return ( <section> {children} <div className="grid"> {team} {analytics} </div> </section> )}Each slot streams independently, with its own loading.tsx and error.tsx
Section titled “Each slot streams independently, with its own loading.tsx and error.tsx”Because each slot is its own subtree, it can define its own loading.tsx and error.tsx — a slow @team fetch shows its own fallback without blocking @analytics, and a crash in one slot is caught by that slot’s own boundary without taking down the others.
// app/dashboard/@team/loading.tsxexport default function TeamLoading() { return <p>Loading team…</p>}// app/dashboard/@team/error.tsx'use client'
export default function TeamError({ error, retry,}: { error: Error & { digest?: string } retry: () => void}) { return ( <div> <h2>Team slot failed to load</h2> <button onClick={() => retry()}>Try again</button> </div> )}default.tsx is the hard-navigation fallback — no default.tsx can mean a real 404
Section titled “default.tsx is the hard-navigation fallback — no default.tsx can mean a real 404”Next.js tracks each slot’s active subpage during client-side (soft) navigation, even for a slot whose URL doesn’t match the current route. On a hard navigation (a fresh request, a reload, or a direct link) there is no client state to recover from, so Next.js falls back to each slot’s default.tsx. Without one, that slot renders notFound().
// app/dashboard/@team/settings/page.tsxexport const dynamic = 'force-dynamic'
export default function TeamSettingsPage() { return <p>team slot: settings</p>}With @team having a real /settings subpage but @analytics and the implicit children slot having no matching page and no default.tsx, a genuinely dynamic route requested directly (no prior client-side navigation state) proves the 404:
GET /dashboard/settings (no default.tsx anywhere, route forced dynamic) -> HTTP 404Adding a default.tsx returning null for every slot that doesn’t match fixes it:
// app/dashboard/@analytics/default.tsxexport default function AnalyticsDefault() { return null}export default function DashboardChildrenDefault() { return null}GET /dashboard/settings (default.tsx added for @analytics and children) -> HTTP 200One caution proven the hard way: if nothing in that exact route tree uses a dynamic API, Next.js can fully prerender an unmatched-slot combination as a static page at build time — in that case the missing-default.tsx path can still respond 200 from the static cache (x-nextjs-prerender: 1), because it’s a real cached page, not a live 404. The “missing default.tsx → 404” rule is about what a genuinely dynamic, uncached request does — always confirm with export const dynamic = 'force-dynamic' (or an actually-dynamic API) in the route you’re testing before trusting a 200/404 result either way.
Intercepting routes: (.) same level, (..) one level up — @slot folders don’t count
Section titled “Intercepting routes: (.) same level, (..) one level up — @slot folders don’t count”Intercepting conventions render a route inside the current layout while the URL still updates. The matcher is based on segment levels, not file-system folders — a @slot folder is invisible to this count. (.) matches the same segment level, (..) one level up, (..)(..) two levels up, and (...) from the app root.
The classic example: a photo modal. Clicking a photo in a feed opens it as a modal over the feed, but visiting the same URL directly renders the full standalone page.
app/ feed/ page.tsx layout.tsx @modal/ default.tsx (..)photo/ [id]/page.tsx // intercepts /photo/[id] as a modal photo/ [id]/page.tsx // the full standalone page@modal doesn’t count as a level, so the intercepting folder’s effective position is one level below feed — and photo is a sibling of feed, one level above that position, which is exactly what (..) reaches. This is proven, not guessed: writing (.)photo here instead (a mistake that looks reasonable — “it’s two folders down from feed, so… same level?”) builds without any error at all, but silently never intercepts:
next build with app/feed/@modal/(.)photo/[id]/page.tsx-> Route (app) lists "ƒ /feed/(.)photo/[id]" — compiles fine
clicking <Link href="/photo/1"> from /feed-> renders "FULL PAGE photo 1" — the modal never appeared, no error eitherRenaming the same file’s folder from (.)photo to (..)photo (identical file contents) is the only change needed:
// app/feed/@modal/(..)photo/[id]/page.tsxexport default async function PhotoModal({ params,}: { params: Promise<{ id: string }>}) { const { id } = await params return <dialog open>MODAL photo {id}</dialog>}export default async function PhotoFullPage({ params,}: { params: Promise<{ id: string }>}) { const { id } = await params return <article>FULL PAGE photo {id}</article>}Proven in a real browser, the same click now shows the modal, and the URL still updates to /photo/1:
document.body.innerText after clicking <Link href="/photo/1"> from /feed ->"root-navfeedopen photo 1MODAL photo 1"But a hard navigation to that same URL — a direct load, or a reload — never goes through the interception at all, because there’s no prior client router state to intercept from:
curl http://localhost:3000/photo/1 (direct request, no client router) ->"FULL PAGE photo 1"
reloading /photo/1 in the browser (page.goto, not a Link click) ->document.body.innerText -> "root-nav\nFULL PAGE photo 1"graph TD L["dashboard/layout.tsx"] --> C["children (page.tsx)"] L --> T["@team slot (own loading/error)"] L --> A["@analytics slot (own loading/error)"] T -->|"hard nav, no default.tsx"| NF["notFound() -> 404"] F["click Link in feed"] -->|"(..)photo intercepts"| M["modal, URL updates"] F -->|"(.)photo — wrong level"| WRONG["compiles, never intercepts"] DIRECT["visit /photo/1 directly or reload"] --> FULL["full standalone page"]
Mental Model
Section titled “Mental Model”Two independent mechanisms are at play, and it’s easy to conflate them. Parallel routes (@slot) are about what renders together in one layout; whether a slot shows its matched content, a fallback, or a 404 depends entirely on whether the current navigation is soft (client router state exists) or hard (nothing to recover, default.tsx or 404 decides). Intercepting routes ((.)/(..)/(...)) are about which file answers a URL during a soft navigation specifically — get the level wrong and Next.js still builds a valid, silently-unreachable route.
Trade-offs
Section titled “Trade-offs”| Convention | Purpose | Failure mode when misused |
|---|---|---|
@slot | Render independent content alongside children in one layout | Forgetting a slot’s default.tsx risks a 404 on hard navigation |
Per-slot loading.tsx/error.tsx | Independent streaming and error isolation per slot | Without them, one slot’s slow fetch or crash can affect the whole layout’s fallback story |
(.) / (..) / (...) | Render a route inside the current layout on soft navigation | Wrong level compiles fine but never intercepts — a silent bug, not a build error |
| Direct/hard navigation to an intercepted URL | Always renders the real standalone page | None — this is the intended, proven behavior |
Common Misconceptions
Section titled “Common Misconceptions”- “A missing default.tsx always 404s on any request to that route.” Proven with a caveat: it 404s for a genuinely dynamic, uncached request — but if nothing in the tree is dynamic, Next.js can prerender that exact unmatched combination as a static 200 at build time.
- ”(.) and (..) are basically interchangeable — just pick the one that ‘feels’ like the folder depth.” Proven false:
(.)photounder@modalcompiles without error but never intercepts, because@slotfolders don’t count toward the level. Only(..)reaches a true sibling offeed. - “If the intercepting route is wrong, the build will tell me.” It won’t — a wrong-level intercept is a silent routing bug, only visible by actually clicking through in a browser.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet uses parallel or intercepting routes, check for these before trusting it:
- Does every slot that can go unmatched have a
default.tsx? Without one, a hard navigation to that state can be a real 404. - Is the intercepting level counted using file-system depth instead of segment depth? Any
@slotfolder in the path must be ignored when counting(.)/(..)/(...). - Does the code assume a build succeeding means the interception works? It doesn’t catch a wrong level — that only shows up by actually clicking the link.
- Is a slow data fetch placed in the shared
childrenpage instead of its own slot? That blocks the whole layout’s initial paint instead of letting an independent slot stream on its own. - Does a test or demo only check the direct/hard-navigation URL for an intercepted route? That always shows the full page by design — the interception only happens via a soft, client-side navigation.
🐛 Spot the bug
// app/gallery/@modal/(.)photo/[id]/page.tsxexport default async function PhotoModal({params,}: {params: Promise<{ id: string }>}) {const { id } = await paramsreturn <dialog open>Photo {id}</dialog>}
// app/gallery/page.tsximport Link from 'next/link'export default function Gallery() {return <Link href="/photo/1">Open photo 1</Link>}Show the answer
@modal is a slot, not a segment, so it doesn’t count toward the interception level. The intercepting folder’s effective position is one level below gallery — and photo lives as a sibling of gallery, one level above that position. (.) (same level) is one level too shallow; only (..) reaches it. Proven directly: this exact structure with (.) compiles with no error and lists a real route in the build output, but clicking the <Link> renders the full standalone /photo/[id] page instead of the modal — a silent miss, not a crash. The fix is renaming the folder from (.)photo to (..)photo with no other changes.