Skip to content

Parallel and Intercepting Routes

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.

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.tsx
app/dashboard/layout.tsx
export 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.tsx
export 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.tsx
export 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 404

Adding a default.tsx returning null for every slot that doesn’t match fixes it:

// app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
return null
}
app/dashboard/default.tsx
export default function DashboardChildrenDefault() {
return null
}
GET /dashboard/settings (default.tsx added for @analytics and children) -> HTTP 200

One 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 either

Renaming the same file’s folder from (.)photo to (..)photo (identical file contents) is the only change needed:

// app/feed/@modal/(..)photo/[id]/page.tsx
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <dialog open>MODAL photo {id}</dialog>
}
app/photo/[id]/page.tsx
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-nav
feed
open photo 1
MODAL 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"]
Parallel slots, per-slot fallbacks, and (.) vs (..) interception

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.

ConventionPurposeFailure mode when misused
@slotRender independent content alongside children in one layoutForgetting a slot’s default.tsx risks a 404 on hard navigation
Per-slot loading.tsx/error.tsxIndependent streaming and error isolation per slotWithout 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 navigationWrong level compiles fine but never intercepts — a silent bug, not a build error
Direct/hard navigation to an intercepted URLAlways renders the real standalone pageNone — this is the intended, proven behavior
  • “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: (.)photo under @modal compiles without error but never intercepts, because @slot folders don’t count toward the level. Only (..) reaches a true sibling of feed.
  • “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.

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 @slot folder 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 children page 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.tsx
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <dialog open>Photo {id}</dialog>
}
// app/gallery/page.tsx
import 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.

What is a folder prefixed with @ such as @team?
Why add default.tsx to a parallel route slot?
Can each parallel slot (e.g. @team and @analytics) define its own loading.tsx and error.tsx?
Built for real and clicked through in a browser via a <Link>, what happens?
app/feed/@modal/(.)photo/[id]/page.tsx
What does the intercepting convention (..) target?
A hard reload of the intercepted URL (e.g. /photo/1, not clicked via Link) shows what, proven in a real browser?