Next.js 24 ▲ Advanced Routing — Parallel routes & intercepted routes
The App Router’s routing model goes beyond nested segments and layouts. Two conventions extend it into composition: parallel routes, which render multiple pages in the same layout through named slots, and intercepting routes, which display a route from another part of the tree within the current layout. The two are most valuable when combined. Parallel routes alone enable dashboards with independently streaming panels; intercepting routes alone enable a route to appear in a different context. Together they produce the modal pattern that preserves the URL, survives a refresh, and closes correctly with the back button .
This chapter covers the @slot convention, the default.js requirement that prevents hard-navigation 404s, the (.) and (..) interception matchers and the route-segment counting rule that confuses them with file paths, and the modal implementation that closes with router.back() rather than a navigation.
Key point: A parallel slot is not a route segment. It does not appear in the URL, and the (..) interception matcher counts route segments, not file-system directories. Inside @modal, a (.) matches the same route level as the parent, even though the file-system path is one directory deeper.
Why these two conventions exist
The composition problem. A nested layout renders its children in one place. A dashboard that needs an analytics panel and an activity feed rendered side by side, each with its own loading state and error boundary, cannot express that with children alone. The @folder convention creates a named slot, and the layout receives it as a prop. The slot streams independently, and its loading.js and error.js are scoped to that slot rather than to the whole page .
The modal problem. A photo in a gallery should open as an overlay when clicked from the feed, but it should render as a full page when the URL is shared or refreshed. Intercepting routes solve this: a soft navigation (a <Link> click) matches the intercepted route and renders it inside the current layout, while a hard navigation (a direct URL or a refresh) bypasses the interception and renders the full page . The URL updates either way, which is what makes the modal shareable.
The counting problem. The (.), (..), (..)(..), and (...) matchers are based on route segments, not file-system directories. A slot folder like @modal is not a segment. The Next.js documentation states this explicitly: “The (..) convention is based on route segments, not the file-system. For example, it does not consider @slot folders in Parallel Routes” . A developer who counts directories instead of segments will use one too many (..) and the interception will not match.
a. The parallel slot and the default.js requirement
A parallel route is a folder whose name begins with @. The folder’s content is passed to the parent layout as a prop named after the folder without the @. The slot does not appear in the URL: app/dashboard/@analytics/views/page.tsx is served at /dashboard/views, not /dashboard/@analytics/views .
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid">
<aside>{team}</aside>
<main>{children}</main>
<section>{analytics}</section>
</div>
)
}
The default.js file is mandatory for every slot. When a user performs a hard navigation, Next.js cannot determine the active state of the slots that do not match the current URL. It renders the slot’s default.js as a fallback. Without the file, the unmatched slot produces a 404 . The children prop is an implicit slot, so the route segment itself also needs a default.js for the case where the parent page’s active state cannot be recovered .
// app/dashboard/@analytics/default.tsx
export default function Default() {
return null
}
The Next.js 16 build fails if a slot’s default.js is missing, which turns the runtime 404 into a build-time error .
b. The interception matchers and the segment counting
The four matchers map to route-segment levels, not file-system levels.
| Matcher | Matches |
|---|---|
(.) | The same route level |
(..) | One route level above |
(..)(..) | Two route levels above |
(...) | The root app directory |
In the photo gallery example, the full page is at app/photo/[id]/page.tsx. The modal slot is at app/@modal/. The intercepted route is placed at app/@modal/(.)photo/[id]/page.tsx. The (.) is correct because the photo segment is at the same route level as the slot’s parent, even though the file-system path is one directory deeper (@modal adds a directory but not a segment) . A (..) would be used when intercepting from a route that is one segment deeper, such as a feed route that opens a photo route from its parent level .
The layout renders the modal slot alongside children. The modal content is a Client Component that closes with router.back().
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
c. The modal component and the close behavior
The modal is a Client Component. It needs the useRouter hook to close itself, and it should close with router.back() rather than a <Link> or router.push('/'). A push adds a new history entry, which breaks the back-button behavior: the user presses back and the modal reopens instead of navigating to the previous page .
// components/modal.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useCallback, useRef } from 'react'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
const overlayRef = useRef<HTMLDivElement>(null)
const handleClose = useCallback(() => {
router.back()
}, [router])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [handleClose])
return (
<div
ref={overlayRef}
onClick={(e) => e.target === overlayRef.current && handleClose()}
className="fixed inset-0 bg-black/50 flex items-center justify-center"
>
<div className="bg-white rounded-lg p-6">{children}</div>
</div>
)
}
The intercepted route renders the modal component with the photo content. The full page route renders the same content without the modal wrapper.
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal'
import { getPhoto } from '@/lib/data'
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.title} />
</Modal>
)
}
// 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="full-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
</div>
)
}
The soft navigation from the gallery renders the modal. A refresh or a direct visit to /photo/123 renders the full page. The URL is the same in both cases, which is the property that makes the modal shareable and the page indexable .
Complete Example Session
// ============================================
// PART 1: THE DASHBOARD SLOTS
// ============================================
// app/dashboard/@analytics/page.tsx
export default function Analytics() {
return <div>Analytics panel</div>
}
// app/dashboard/@team/page.tsx
export default function Team() {
return <div>Team panel</div>
}
// ============================================
// PART 2: THE DASHBOARD LAYOUT
// ============================================
// app/dashboard/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid">
<aside>{team}</aside>
<main>{children}</main>
<section>{analytics}</section>
</div>
)
}
// ============================================
// PART 3: THE DEFAULT FILES
// ============================================
// app/dashboard/@analytics/default.tsx
export default function Default() {
return null
}
// app/dashboard/@team/default.tsx
export default function Default() {
return null
}
// ============================================
// PART 4: THE MODAL SLOT
// ============================================
// app/@modal/default.tsx
export default function Default() {
return null
}
// ============================================
// PART 5: THE ROOT LAYOUT WITH THE SLOT
// ============================================
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
// ============================================
// PART 6: THE INTERCEPTED ROUTE
// ============================================
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal'
import { getPhoto } from '@/lib/data'
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.title} />
</Modal>
)
}
// ============================================
// PART 7: THE FULL PAGE ROUTE
// ============================================
// app/photo/[id]/page.tsx
import { getPhoto } from '@/lib/data'
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<div className="full-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
</div>
)
}
// ============================================
// PART 8: THE MODAL COMPONENT
// ============================================
'use client'
import { useRouter } from 'next/navigation'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<div onClick={() => router.back()} className="overlay">
<div onClick={(e) => e.stopPropagation()}>{children}</div>
</div>
)
}
// ============================================
// PART 9: THE LINK FROM THE GALLERY
// ============================================
// app/gallery/page.tsx
import Link from 'next/link'
export default function Gallery({ photos }: { photos: Photo[] }) {
return photos.map((p) => (
<Link key={p.id} href={`/photo/${p.id}`}>
<img src={p.thumbnail} alt={p.title} />
</Link>
))
}
// ============================================
// PART 10: THE DECISION
// ============================================
// Dashboard panels → @slot + default.js
// Modal with shareable URL → @modal + (.) + router.back()
// Direct URL → full page route
The ten parts covered the dashboard slots, the layout, the default files, the modal slot, the root layout, the intercepted route, the full page route, the modal component, the gallery link, and the decision.
Quick Reference
Parallel routes
| Convention | Effect |
|---|---|
@folder | Named slot, passed as a layout prop |
| Slot in URL | Not present |
default.js | Fallback for hard navigation |
Missing default.js | 404 on refresh (build error in Next.js 16) |
Intercepting routes
| Matcher | Matches |
|---|---|
(.) | Same route level |
(..) | One level up |
(..)(..) | Two levels up |
(...) | App root |
Modal behavior
| Navigation | Rendered |
|---|---|
Soft (<Link>) | Intercepted route (modal) |
| Hard (refresh, direct URL) | Full page route |
| Close | router.back() |
<Link> to close | Incorrect (adds history entry) |
The segment counting rule
| File system | Route segments |
|---|---|
app/@modal/(.)photo/[id] | @modal is a slot, not a segment |
(.) matches | The photo segment at the parent level |
Best Practices
✅ Do This:
// Add default.js to every slot
export default function Default() { return null } // ✅
// Use (.) inside @modal to intercept the sibling segment
app/@modal/(.)photo/[id]/page.tsx // ✅
// Close the modal with router.back()
const router = useRouter(); router.back() // ✅
// Keep the full page route for direct navigation
app/photo/[id]/page.tsx // ✅
❌ Don’t Do This:
// Omit the default.js file
// The slot 404s on refresh // ❌
// Count file-system directories for the matcher
// @modal is not a segment, so (..) is wrong here // ❌
// Close the modal with router.push('/')
router.push('/') // ❌ (breaks back button)
// Use a parallel slot as a URL segment
// /dashboard/@analytics does not exist // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| 404 on refresh | Slot missing default.js | Add the file |
| Interception does not match | Wrong number of (..) | Count route segments, not directories |
| Modal reopens on back | Closed with push instead of back | Use router.back() |
| Full page renders inside the layout | Interception applied on hard navigation | Expected; hard navigation bypasses interception |
| Build fails in Next.js 16 | Slot missing default.js | Add the file before building |
Real-World Examples
1. The dashboard slot
app/dashboard/@analytics/page.tsx
2. The slot default
app/dashboard/@analytics/default.tsx
3. The modal slot
app/@modal/default.tsx
4. The intercepted route
app/@modal/(.)photo/[id]/page.tsx
5. The full page
app/photo/[id]/page.tsx
6. The layout
{children}
{modal}
7. The close
router.back()
8. The link
<Link href={`/photo/${p.id}`}>...</Link>
9. The matcher
(.) same level; (..) one up; (...) root
10. The rule
@slot is not a segment
Visual
The parallel slot
┌─────────────────────────────────────────────────────────────┐
│ app/dashboard/ │
│ ├── @analytics/ │
│ │ ├── page.tsx │
│ │ └── default.tsx │
│ ├── @team/ │
│ │ ├── page.tsx │
│ │ └── default.tsx │
│ ├── layout.tsx ← receives analytics, team, children │
│ └── page.tsx │
│ │
│ URL: /dashboard (slots not in the URL) │
└─────────────────────────────────────────────────────────────┘
The interception matcher
┌─────────────────────────────────────────────────────────────┐
│ app/@modal/(.)photo/[id]/page.tsx │
│ │ │
│ └── (.) matches the same route level │
│ │
│ @modal is a slot, not a segment. │
│ The photo segment is one route level up, not two. │
└─────────────────────────────────────────────────────────────┘
The modal flow
┌─────────────────────────────────────────────────────────────┐
│ Gallery ──<Link>──► /photo/123 │
│ │ │
│ ├── Soft navigation ──► intercepted modal │
│ │ │
│ └── Hard navigation ──► full page │
│ │
│ URL is /photo/123 in both cases. │
└─────────────────────────────────────────────────────────────┘
The close behavior
┌─────────────────────────────────────────────────────────────┐
│ router.back() │
│ → closes the modal │
│ → returns to the gallery │
│ │
│ router.push('/') │
│ → adds a history entry │
│ → back button reopens the modal │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Parallel route convention | @folder |
| Slot URL presence | None |
default.js requirement | Every slot, plus the children slot |
| Interception matchers | (.), (..), (..)(..), (...) |
| Matcher basis | Route segments, not file paths |
| Modal close | router.back() |
| Soft navigation | Intercepted route |
| Hard navigation | Full page route |
| Next.js 16 build | Fails if a slot’s default.js is missing |
Key takeaways:
- A parallel slot is a named prop on the layout, not a URL segment. The
@folderconvention creates the slot, and the layout receives it alongsidechildren. The slot streams independently, with its ownloading.jsanderror.js, and it does not appear in the URL . default.jsis mandatory for every slot. A hard navigation cannot recover the slot’s active state, so Next.js renders thedefault.jsfallback. Without the file, the slot produces a 404, and in Next.js 16 the build fails .- The interception matchers count route segments, not file-system directories. The
@modalfolder is a slot, so it is skipped when counting. A(.)inside@modalmatches the same route level as the parent, even though the file path is one directory deeper . - The modal pattern combines a parallel slot with an intercepted route. The soft navigation renders the intercepted route inside the slot; the hard navigation bypasses the interception and renders the full page. The URL is the same in both cases, which makes the modal shareable and the page indexable .
- The modal must close with
router.back(). Apushor a<Link>adds a new history entry, which breaks the back-button behavior. Thebackcall returns to the gallery and closes the modal in one action . - The conditional slot rendering is a server-side decision. A layout can choose which slot to render based on the user’s role. The slots that are not rendered still execute on the server, so the authorization must be inside each slot’s page or in the data access layer, not only in the layout’s condition .
Remember: Parallel routes compose the layout, and intercepting routes change the context of a navigation. The @slot convention creates the composition, the default.js makes it survive a hard navigation, and the (.) matcher makes the modal appear on a soft navigation. The counting rule is the one that trips up most developers: the matcher follows route segments, and a slot folder is not a segment. The modal closes with router.back(), not with a navigation, because the back button is the behavior that the pattern is designed to preserve.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!