Next.js: Parallel Routes and Intercepting Routes — Build URL-Driven Modals and Complex Layouts
Parallel routes let a single layout render multiple independent pages at once. Combine them with intercepting routes and you get URL-aware modals with zero hacks.
Next.js: Parallel Routes and Intercepting Routes — Build URL-Driven Modals and Complex Layouts
You open a photo on Instagram. The URL changes to /p/abc123. You hit refresh — still on the photo. You copy the link and send it to a friend — they open the full photo page. But you saw a modal over the feed. Same URL, different UI based on how you got there.
That's the pattern these two Next.js App Router features make possible. Together they solve one of the oldest frontend problems: modals that need to be both ephemeral overlays and linkable pages.
Let's break down how each feature works, then combine them.
Parallel Routes — Multiple Pages, One Layout
Normally a layout renders one children slot. Parallel routes let you render multiple independent route segments side by side. Each segment has its own loading state, error boundary, and can be navigated independently.
The @slot convention
You define a parallel route by prefixing a folder name with @. That folder is called a slot.
app/
dashboard/
layout.tsx ← receives { children, analytics, team } props
page.tsx ← fills the implicit @children slot
@analytics/
page.tsx
@team/
page.tsx
The layout automatically gets each named slot as a prop:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="grid grid-cols-3 gap-4 p-6">
<main className="col-span-2">{children}</main>
<aside className="space-y-4">
{analytics}
{team}
</aside>
</div>
)
}Each slot is a full route segment — it gets its own loading.tsx, error.tsx, and can house dynamic routes. That means your analytics widget can show a loading spinner independently while the main content has already rendered. No waterfall, no shared suspense boundary catching everything.
The default.tsx problem (don't skip this)
Here's where people get burned, and here's where most of what's written about it is out of date.
During soft navigation Next.js tracks which subpage each slot is currently showing. On a hard navigation — typing the URL, hitting refresh, clicking a link from another site — it can't recover that state, so it has to render every slot from the URL alone. /dashboard is fine: @analytics/page.tsx matches it. The problem is a sibling route. Navigate straight to /dashboard/revenue and the main segment has a page but @analytics and @team have nothing that matches.
Older articles (and older Next.js) will tell you this returns a 404. On 16.1.6 it doesn't — it fails the build:
$ next build
Missing required default.js file for parallel route at /dashboard/@analytics
The parallel route slot "@analytics" is missing a default.js file. When using
parallel routes, each slot must have a default.js file to serve as a fallback.
Create a default.js file at: /dashboard/@analytics/default.js
https://nextjs.org/docs/messages/slot-missing-defaultThat's an improvement — you find out at build time instead of from a bug report — but it means the symptom you're looking for is a red build, not a mysterious 404. In next dev it's a 500 on every route in the app until you add the file, which is disorienting the first time.
// app/dashboard/@analytics/default.tsx
// Required — renders when no subpage matches during hard navigation
export default function AnalyticsDefault() {
return null // or a skeleton, or a placeholder
}If you actually want the old 404 behaviour for a slot, you have to ask for it:
// app/dashboard/@analytics/default.tsx
import { notFound } from 'next/navigation'
export default function AnalyticsDefault() {
notFound()
}@slot folder needs a default.tsx, and so does the implicit children slot if the parent's active state can't be recovered — without one, children falls back to the 404 page. Adding default.tsx returning null to every slot is the cheap, boring, correct move.Slots don't affect URLs
This trips people up. The folder @analytics does not appear in the URL. /dashboard renders @analytics/page.tsx at the same time as page.tsx. Navigating to /dashboard/revenue would render @analytics/revenue/page.tsx alongside page.tsx (if that page exists in the main segment too).
Slot folders are invisible to the URL. Route groups (folders wrapped in parentheses) are also URL-invisible. Keep that in mind when constructing intercepting routes.
Intercepting Routes — Context-Aware Navigation
Intercepting routes let you show a different UI for the same URL depending on how the user got there. Navigate client-side (soft navigation via <Link>) and you see one thing. Navigate directly (hard navigation, refresh) and you see another.
The (..) notation
You intercept a route by wrapping a folder name in parentheses with dots indicating how many segments to traverse up:
| Convention | Meaning |
|---|---|
(.)folder | Intercept a sibling route (same level) |
(..)folder | Intercept a route one level up |
(..)(..)folder | Intercept a route two levels up |
(...)folder | Intercept from the app root |
Important: the dots count URL segments, not file-system directories. @slot folders and (group) folders are transparent to this counting.
So if your route is app/photos/[id]/page.tsx and you want to intercept it from a sibling slot, you use (.)photos/[id].
The Combined Pattern: URL-Aware Modals
This is where things click. The classic Unsplash pattern:
- User browses
/feed— sees a photo grid - Clicks a photo — URL becomes
/photos/42, but they see a modal over the feed - Refresh — the modal is gone, they see
/photos/42as a standalone full page - Copy + share the URL — recipient sees the full photo page, not the modal
app/
layout.tsx ← root layout with @modal slot
page.tsx ← home feed
@modal/
default.tsx ← returns null (modal closed state)
(.)photos/
[id]/
page.tsx ← modal content (intercepted)
photos/
[id]/
page.tsx ← full standalone page (direct access)
The root layout receives the modal slot:
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html lang="en">
<body>
{children}
{modal} {/* renders null by default, modal content when intercepted */}
</body>
</html>
)
}The default slot returns null so no modal renders normally:
// app/@modal/default.tsx
export default function ModalDefault() {
return null
}The intercepted route renders the modal UI:
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal'
import { PhotoDetail } from '@/components/photo-detail'
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }> // a Promise since Next.js 15
}) {
const { id } = await params
return (
<Modal>
<PhotoDetail id={id} />
</Modal>
)
}The standalone page for direct access:
// app/photos/[id]/page.tsx
import { PhotoDetail } from '@/components/photo-detail'
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<div className="max-w-4xl mx-auto p-8">
<PhotoDetail id={id} />
</div>
)
}The modal component itself:
// components/modal.tsx
'use client'
import { useRouter } from 'next/navigation'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center"
onClick={() => router.back()}
>
<div
className="bg-white rounded-xl max-w-2xl w-full p-6"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
}router.back() to close the modal — not router.push('/'). back() pops the history entry that opened the modal, returning the user to whatever page they were actually on, with scroll position intact. push('/') sends everyone to the same hardcoded route and adds an entry, so the browser Back button re-opens the modal they just dismissed.How the flow actually works
During soft navigation, Next.js detects the (.)photos interceptor inside @modal and renders that instead of the real photos/[id]/page.tsx. The URL updates to /photos/42 but the @modal slot is now active, and the children slot (your feed) stays exactly where it was.
During hard navigation — refresh, direct URL, external link — the interceptor doesn't fire. Next.js renders photos/[id]/page.tsx as a normal page, and @modal/default.tsx (returning null) fills the modal slot.
You can watch the decision happen without opening a browser. A soft navigation is just a request carrying the RSC headers, so building the tree above on 16.1.6 and hitting the same URL two ways:
# Hard navigation
curl localhost:3000/photos/42
# → <div data-kind="FULLPAGE">FULL PAGE for photo 42</div>
# Soft navigation from /feed (what <Link> sends)
curl -H 'RSC: 1' -H 'Next-Url: /feed' localhost:3000/photos/42
# → MODAL for photo 42Same route, same server, two different components — selected by Next-Url, the header the client router sends to say where the user currently is. That header is the entire mechanism.
Variations
Split dashboard with independent loading
Parallel routes without any interception — useful when you want completely independent data fetching per panel:
app/
dashboard/
layout.tsx
page.tsx ← main content
@activity/
page.tsx ← recent activity feed
loading.tsx ← its own loading UI
@metrics/
page.tsx ← analytics widgets
loading.tsx
error.tsx ← isolated error state
Each slot's loading.tsx fires independently. A slow metrics panel shows its own skeleton while the activity feed next to it has already rendered — whichever resolves first paints first. No Promise.all, no shared suspense boundary eating everything.
Auth modal with intercepting routes
A common pattern: /login is a real page, but clicking "Sign in" anywhere on the site opens it as a modal without losing your place:
app/
layout.tsx ← has @auth slot
page.tsx
login/
page.tsx ← full login page (direct access)
@auth/
default.tsx ← null
(.)login/
page.tsx ← login modal
Conditional tab content
You can use parallel routes to build tabs where each tab has its own URL, back/forward history, and loading state:
// Each tab is a real route: /dashboard/overview, /dashboard/settings, etc.
// But they all render within the same dashboard layout
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<nav>
<Link href="/dashboard/overview">Overview</Link>
<Link href="/dashboard/settings">Settings</Link>
</nav>
{children}
</div>
)
}No @slots needed here — just normal nested routes. Parallel routes become worthwhile when you need simultaneous rendering of independent sections.
When NOT to use this
Simple modals with no URL requirement. If the modal content doesn't need a shareable URL, doesn't need to survive a refresh, and doesn't need to be accessible via direct link — just use a state-based modal. useState(false) + a Dialog component is 10 lines. The parallel + intercepting routes setup is 6+ files. Match the tool to the need.
Tabs that only need client state. If your tabs just swap visible content and don't need URL-driven navigation, a simple activeTab state variable is cleaner and faster to implement.
When you're on Pages Router. This entire pattern is App Router-only. There's no equivalent in the Pages Router.
Deeply nested modal hierarchies. Opening a modal from inside a modal with this pattern gets complicated fast. The intercepting routes convention gets hard to reason about when you're three levels deep. For nested modals, a state-management approach (zustand, context, or a modal manager library) is usually cleaner.
When the gotchas outweigh the benefits. The default.tsx requirement, the dot-counting in intercepting routes, the fact that params is a Promise since Next.js 15 — these add up. If your team isn't familiar with the App Router's mental model, the debugging cost when something breaks can be high.
params: Promise<{ id: string }>, and 16 keeps it that way. You must await params before destructuring. This affects all dynamic routes, including intercepted ones and default.tsx files, which receive params too.The gotchas worth memorizing
Missing default.tsx — no longer silent, but still the first thing to check. On 16 it stops the build with slot-missing-default; on 14 and earlier it was a 404 on refresh that worked fine after client-side navigation, which is the version most existing write-ups describe. Either way: add default.tsx to every slot, even if it just returns null.
router.back() vs router.push() for closing modals. Always back(). Push adds a history entry, so Back re-opens what the user just closed.
Slot folders don't count as URL segments. @modal/(.)photos — the @modal part is invisible to the dot counting. (.) still means "same URL level as the current route segment."
Route groups don't count either. (app)/photos/(.)detail — the (app) wrapper is also transparent to the dots.
When the pattern is right for your use case, it genuinely eliminates the modal mess: no searchParams hacks, no hiding/showing based on history state, no copy-paste of modal content into two components. The URL is the state. The file system is the logic. That's the deal.
Comments (0)
No comments yet. Be the first to share your thoughts!