When Next.js introduced parallel routes and intercepting routes in the App Router, the docs had this abstract diagram of slots and modals that made both of us stare at the screen for a while. We understood the words individually. Together? Not so much. It took building a few real features before it clicked — and once it did, we genuinely wished we'd had this earlier.
So here's the explanation we wish existed: concrete examples, the actual file structure, and the 'oh THAT's why' moments included.
What Problem Are These Actually Solving?
Before getting into the how, let's be honest about the why. You've built this before: a page with a list of items, user clicks one, a modal appears showing the item detail. When they share the URL, the modal is open. When they refresh, the modal is still there. When they hit back, the modal closes instead of navigating away from the page entirely.
That pattern — which Twitter/X uses for tweets, Vercel uses for deployment details, GitHub uses for file previews — used to require a bunch of client-side state, URL param juggling, and carefully placed `useEffect` calls. It was never clean. Parallel routes and intercepting routes are Next.js saying 'we'll handle this at the routing level.'
Parallel Routes: Rendering Two Things in One Layout
Parallel routes let a single layout render multiple pages simultaneously, each in their own 'slot'. The syntax uses the `@folder` convention. Here's the simplest version: a dashboard with a sidebar feed and a main content area that can each navigate independently.
Your file structure would look like this:
app/
dashboard/
layout.tsx ← receives @analytics and @team as props
page.tsx
@analytics/
page.tsx ← /dashboard renders this in the analytics slot
weekly/
page.tsx ← /dashboard/weekly renders this in the analytics slot
@team/
page.tsx ← /dashboard renders this in the team slot
settings/
page.tsxThe layout receives these slots as props alongside `children`. You can place them wherever you want in the UI:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard-grid">
<main>{children}</main>
<aside className="analytics-panel">{analytics}</aside>
<aside className="team-panel">{team}</aside>
</div>
)
}Now when a user navigates to `/dashboard/weekly`, only the `@analytics` slot updates to show the weekly page. The `@team` slot stays exactly where it is, rendering whatever it was rendering before. No state management. No prop drilling. The URL reflects the state of both panels.
Parallel routes are not just for modals. Any UI where multiple independent regions need to reflect URL state is a candidate — dashboards, split views, feed + detail panels.
The default.tsx File (Don't Skip This)
Here's what bites everyone: when you navigate to a URL that matches one slot but not another, Next.js needs to know what to render in the unmatched slot. If you don't have a `default.tsx`, you'll get a 404 on the whole page. Not a great experience.
// app/dashboard/@team/default.tsx
// Renders when no matching page exists for this slot
export default function TeamDefault() {
return <TeamPanel /> // or null, or a skeleton
}Think of `default.tsx` as the fallback state for a slot. Every slot that might be in an 'unmatched' state needs one. We always add these first now, before any of the actual slot pages. Saves a lot of 'why is my dashboard 404ing' confusion.
Intercepting Routes: The Modal Pattern Done Right
Intercepting routes are where things get really interesting. The idea: when you navigate to `/photos/42` from within your app, show a modal. But when you navigate to `/photos/42` directly (fresh tab, shared link), show the full page. Same URL, two different rendering behaviors depending on how you got there.
The convention uses parentheses with dots to indicate 'intercept this route from this level':
- (.) — intercept from the same level
- (..) — intercept from one level up
- (..)(..) — intercept from two levels up
- (...) — intercept from the root
Let's build the classic photo feed with modal. Here's the structure:
app/
photos/
page.tsx ← the photo grid
[id]/
page.tsx ← full photo page (direct navigation)
@modal/
(..photos)/[id]/
page.tsx ← intercepted modal version
default.tsx ← null — no modal by defaultThe `(..)photos` means: intercept the route one level up that matches `photos/[id]`. When navigating from the photos grid to a specific photo, Next.js intercepts the navigation and renders the modal slot instead. When you hit refresh or navigate directly, there's no interception — you get the full page.
// app/photos/layout.tsx
export default function PhotosLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}// app/photos/@modal/(..photos)/[id]/page.tsx
import { PhotoModal } from '@/components/photo-modal'
export default async function InterceptedPhotoPage({
params,
}: {
params: { id: string }
}) {
const photo = await getPhoto(params.id)
return <PhotoModal photo={photo} />
}
// app/photos/[id]/page.tsx — the full page version
export default async function PhotoPage({
params,
}: {
params: { id: string }
}) {
const photo = await getPhoto(params.id)
return (
<div className="full-photo-page">
<img src={photo.url} alt={photo.alt} />
<PhotoDetails photo={photo} />
</div>
)
}The `@modal/default.tsx` returns null so there's no modal rendered when you're just looking at the grid. When you click a photo, the modal slot fills. When you close the modal, you call `router.back()` and the slot empties again. The URL throughout all of this reflects `/photos/42` — shareable, bookmarkable, correct.
Combining Both: The Full Pattern
The real power comes from combining parallel routes (for the slot structure) with intercepting routes (for the interception logic). This is exactly what apps like Vercel's dashboard do — you click a deployment, see a modal with details, can share the URL, and if someone opens it fresh they get the full deployment page.
A practical version for a SaaS app: a list of users, click one to see their profile in a modal, close to return to the list.
app/
(dashboard)/
users/
layout.tsx ← has @modal slot
page.tsx ← user list
[userId]/
page.tsx ← full user profile page
@modal/
default.tsx ← export default function() { return null }
(..users)/[userId]/
page.tsx ← intercepted modal// app/(dashboard)/users/@modal/(..users)/[userId]/page.tsx
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { UserProfile } from '@/components/user-profile'
import { ModalBackButton } from '@/components/modal-back-button'
export default async function UserModalPage({
params,
}: {
params: { userId: string }
}) {
const user = await getUserById(params.userId)
return (
<Dialog defaultOpen>
<DialogContent>
<ModalBackButton /> {/* calls router.back() on close */}
<UserProfile user={user} />
</DialogContent>
</Dialog>
)
}// components/modal-back-button.tsx
'use client'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
export function ModalBackButton() {
const router = useRouter()
return (
<Button
variant="ghost"
onClick={() => router.back()}
className="absolute top-4 right-4"
>
Close
</Button>
)
}One thing worth noting: the `router.back()` approach only works correctly when the user actually navigated there from within your app. If they landed on the modal URL directly (no history to go back to), `router.back()` might take them somewhere unexpected. A safer pattern is to use `router.push('/users')` as the close handler, or detect whether there's history available.
When to Use These vs. When Not To
We've seen developers reach for intercepting routes for every modal in their app. Don't. The added complexity is only worth it when the shareable URL behavior actually matters to your users.
- Use intercepting routes when the URL needs to be shareable/bookmarkable while showing a modal-like UI
- Use parallel routes when multiple independent sections of a page need their own URL state
- For simple modals that don't need URL state, just use useState and a Dialog component — seriously
- For forms in modals that submit and redirect, plain client-side modals are usually simpler
- The complexity is worth it for content-heavy items (photos, documents, profiles) where users will share links
We learned this while building one of our templates on peal.dev — we initially used intercepting routes for a settings modal that absolutely did not need a URL. Added an hour of debugging for zero user benefit. Ripped it out, replaced it with a regular Dialog, done in ten minutes.
Common Mistakes and How to Fix Them
A few things that tripped us up and will probably trip you up too:
- Forgetting default.tsx in every slot — results in 404s when a slot has no match for the current URL
- Wrong dot notation — (..) means one level up in the route hierarchy, not the file system. Count route segments, not folders
- Placing the @modal folder at the wrong level — it needs to be a sibling of the route it's intercepting
- Not wrapping modal content in a Dialog with defaultOpen — the interception works but nothing actually opens
- Using router.back() when the user might have landed directly on the intercepted URL — add a fallback redirect
If your intercepted route renders a blank page instead of a modal, check two things: your @modal/default.tsx exists and returns null, and your layout.tsx is rendering the modal slot prop.
The file naming takes a minute to internalize but once you've built it twice it becomes second nature. The mental model is: parallel routes are about 'what slots exist in this layout', and intercepting routes are about 'when navigating to this URL from inside the app, show THIS instead of the real page'. Two separate concerns that often work together.
It's one of those Next.js features that feels over-engineered until you need it — then it saves you from what would've been a fragile mess of query params and client state. Worth having in the toolbox.
