
What Are Parallel Routes and Intercepting Routes in Next.js?
Some interfaces do not fit the one-URL, one-page model. A dashboard shows a team panel and an analytics panel side by side, each with its own loading state and its own sub-navigation. A photo feed opens a picture in a modal, yet the URL changes so the photo can be shared, and refreshing the page shows the photo on its own page. A login form opens over the current page from the header, but /login still works as a standalone page.
You can build all of these with client state and some careful useEffect code, but you lose things along the way: shareable URLs, back-button behavior, server rendering, and independent streaming. The Next.js App Router has two routing features designed for exactly these cases: parallel routes and intercepting routes. They are powerful, but they also have the steepest learning curve in the router, mostly because of how they behave on a hard refresh.
This article explains both features from the ground up: slots and how they are passed to layouts, default.tsx and the difference between soft and hard navigation, independent loading and error states, conditional slots, the four interception conventions, and a complete, working photo gallery modal that combines both features. It also covers the Next.js 16 change that makes default.tsx mandatory for every slot.
Routing Recap
Parallel and intercepting routes build on the basic App Router model: folders are route segments, page.tsx makes a segment publicly accessible, and layout.tsx wraps the segment and everything below it. If you need a refresher, see how Next.js handles routing for nested pages and how to use custom layouts in Next.js.
Two special folder conventions are involved here:
| Convention | Example | Meaning |
|---|---|---|
@name | app/@analytics | A named slot for parallel routes, not part of the URL |
(.)name | app/@modal/(.)photos | An intercepting route that matches the same level |
(..)name | app/feed/(..)photos | An intercepting route that matches one level up |
(...)name | app/a/b/(...)photos | An intercepting route that matches from the app root |
Do not confuse (.) with route groups such as (marketing). Route groups use a plain name in parentheses to organize files without affecting the URL. Interception markers always start with dots.
What Parallel Routes Are
Parallel routes let one layout render several pages at the same time, each from its own folder. Each folder is a slot, named with an @ prefix. Slots are passed to the parent layout as props, alongside the usual children prop.
Here is a dashboard with two slots:
app/
└── dashboard/
├── layout.tsx
├── page.tsx
├── default.tsx
├── @team/
│ ├── page.tsx
│ └── default.tsx
└── @analytics/
├── page.tsx
└── default.tsx
The layout receives team and analytics as props and decides where to render them:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
team,
analytics,
}: {
children: React.ReactNode;
team: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div className="space-y-6 p-6">
{children}
<div className="grid gap-6 lg:grid-cols-2">
<section className="rounded-lg border p-4">{team}</section>
<section className="rounded-lg border p-4">{analytics}</section>
</div>
</div>
);
}
// app/dashboard/@team/page.tsx
import { getTeamMembers } from "@/lib/team";
export default async function TeamSlot() {
const members = await getTeamMembers();
return (
<>
<h2 className="mb-3 font-semibold">Team</h2>
<ul className="space-y-1">
{members.map((m) => (
<li key={m.id}>{m.name}</li>
))}
</ul>
</>
);
}
// app/dashboard/@analytics/page.tsx
import { getWeeklyVisitors } from "@/lib/analytics";
export default async function AnalyticsSlot() {
const visitors = await getWeeklyVisitors();
return (
<>
<h2 className="mb-3 font-semibold">Analytics</h2>
<p className="text-3xl font-bold">{visitors.toLocaleString()}</p>
<p className="text-sm text-gray-500">visitors this week</p>
</>
);
}
Key facts about slots:
- Slots do not affect the URL.
app/dashboard/@team/page.tsxrenders at/dashboard, not/dashboard/@team. A file atapp/dashboard/@analytics/visitors/page.tsxrenders at/dashboard/visitors. childrenis an implicit slot.app/dashboard/page.tsxis equivalent toapp/dashboard/@children/page.tsx. That is whychildrenalso needs adefault.tsx, which we will come to shortly.- Slots render on the server in parallel. Each slot fetches its own data, so a slow analytics query does not delay the team list.
- Slots at the same level share rendering mode. If one slot is dynamic, all slots at that level are rendered dynamically.
Independent Loading and Error States
Because each slot is its own route subtree, each can have its own loading.tsx and error.tsx:
// app/dashboard/@analytics/loading.tsx
export default function Loading() {
return <div className="h-24 animate-pulse rounded bg-gray-200" />;
}
// app/dashboard/@analytics/error.tsx
"use client";
export default function AnalyticsError({
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<p className="text-red-600">Analytics are unavailable right now.</p>
<button type="button" onClick={reset} className="mt-2 underline">
Try again
</button>
</div>
);
}
If the analytics service fails, only that panel shows an error; the team panel and the rest of the dashboard keep working. Each slot also streams independently. This is the most practical everyday use of parallel routes, even if you never build a modal.
Soft Navigation, Hard Navigation, and default.tsx
This is the part that confuses most people. Consider adding a settings page to the team slot only:
app/dashboard/
├── @team/
│ ├── page.tsx -> /dashboard
│ └── settings/page.tsx -> /dashboard/settings
└── @analytics/
└── page.tsx -> /dashboard
When the user is on /dashboard and clicks a link to /dashboard/settings, what should the analytics slot show? It has no settings page.
Next.js answers this differently depending on how the user arrived:
| Navigation type | How it happens | Unmatched slot shows |
|---|---|---|
| Soft navigation | Clicking a Link, router.push | Whatever it was already showing |
| Hard navigation | Refresh, direct URL, opening a new tab | Its default.tsx |
On soft navigation, the router keeps track of each slot's active state, so analytics simply keeps rendering its previous page. On a hard navigation, there is no previous state to recover, so Next.js renders the slot's default.tsx.
In Next.js 16, every slot must have a default.tsx, and the build fails without one. In earlier versions, a missing default.tsx caused a 404 on hard navigation, which was a common source of mysterious production 404s. If you want the old behavior, write it explicitly:
// app/dashboard/@analytics/default.tsx
import { notFound } from "next/navigation";
export default function Default() {
notFound();
}
More often, you want the slot to fall back to its main content or to nothing:
// app/dashboard/@team/default.tsx
export { default } from "./page";
// app/dashboard/default.tsx
export default function Default() {
return null;
}
Remember the implicit children slot. If /dashboard/settings only exists inside @team, a hard refresh needs app/dashboard/default.tsx to know what to render for children.
Tabs Inside a Slot
A slot can have its own layout.tsx, which lets it navigate independently of the rest of the page. This is how you build a panel with tabs:
// app/dashboard/@analytics/layout.tsx
import Link from "next/link";
export default function AnalyticsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<nav className="mb-4 flex gap-4 text-sm">
<Link href="/dashboard">Overview</Link>
<Link href="/dashboard/page-views">Page views</Link>
<Link href="/dashboard/visitors">Visitors</Link>
</nav>
{children}
</>
);
}
Add @analytics/page-views/page.tsx and @analytics/visitors/page.tsx, and clicking a tab swaps only the analytics panel while the team panel stays put. To highlight the active tab from a Client Component, use useSelectedLayoutSegment with the slot name as the parallelRouteKey argument:
// app/dashboard/analytics-tab.tsx
"use client";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
export function AnalyticsTab({
href,
segment,
label,
}: {
href: string;
segment: string | null;
label: string;
}) {
const active = useSelectedLayoutSegment("analytics");
return (
<Link
href={href}
className={
active === segment ? "font-semibold underline" : "text-gray-500"
}
>
{label}
</Link>
);
}
Conditional Slots
A layout can choose which slot to render, for example based on the user's role:
// app/admin/layout.tsx
import { getCurrentUser } from "@/lib/auth";
export default async function AdminLayout({
admin,
viewer,
}: {
admin: React.ReactNode;
viewer: React.ReactNode;
}) {
const user = await getCurrentUser();
return user?.role === "admin" ? admin : viewer;
}
There is an important security caveat: both slots render on the server regardless of which one the layout returns. The @admin page runs its data fetches for every visitor. The condition only decides what is displayed. Always authorize inside the admin slot's page or in your data access layer, never rely on the layout's conditional alone. For authentication patterns, see how to implement authentication in a Next.js app.
What Intercepting Routes Are
Intercepting routes let you load a route from elsewhere in the app inside the current layout during client-side navigation. The URL in the address bar changes to the intercepted route, but the user stays in their current context.
The classic example: a feed shows photo thumbnails. Clicking one changes the URL to /photos/42 and shows the photo in a modal over the feed. But if someone opens /photos/42 directly, refreshes, or follows a shared link, they get the full photo page. No interception happens on a hard navigation.
The Four Conventions
Interception markers work like relative paths, but for route segments:
| Marker | Matches | Example folder |
|---|---|---|
(.) | Segments on the same level | app/@modal/(.)photos |
(..) | Segments one level above | app/feed/(..)photos |
(..)(..) | Segments two levels above | app/a/b/(..)(..)photos |
(...) | Segments from the app root | app/a/b/c/(...)photos |
The critical detail: the levels are counted in route segments, not folders. Slot folders like @modal and route groups like (shop) are not segments. A folder at app/@modal/(.)photos intercepts /photos with (.), because @modal does not count as a level.
Building a Shareable Photo Modal
This is the pattern that combines both features. The finished file tree:
app/
├── layout.tsx
├── default.tsx
├── page.tsx -> gallery at /
├── photos/
│ └── [id]/
│ └── page.tsx -> full photo page at /photos/42
├── @modal/
│ ├── default.tsx -> renders nothing
│ ├── [...catchAll]/page.tsx -> closes the modal on other routes
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx -> intercepted photo in a modal
└── lib/
└── photos.ts
Step 1: Shared Data
// app/lib/photos.ts
export type Photo = { id: string; title: string; src: string; author: string };
const photos: Photo[] = [
{
id: "1",
title: "Sundarbans at dawn",
src: "/images/photos/1.jpg",
author: "Rafi",
},
{
id: "2",
title: "Cox's Bazar sunset",
src: "/images/photos/2.jpg",
author: "Mim",
},
{
id: "3",
title: "Sylhet tea garden",
src: "/images/photos/3.jpg",
author: "Arif",
},
];
export async function getPhotos() {
return photos;
}
export async function getPhoto(id: string) {
return photos.find((photo) => photo.id === id) ?? null;
}
Step 2: The Root Layout with a Modal Slot
// app/layout.tsx
import "./globals.css";
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{modal}
</body>
</html>
);
}
// app/@modal/default.tsx
export default function Default() {
return null;
}
// app/default.tsx
export default function Default() {
return null;
}
The @modal slot renders nothing by default, so pages look normal until a photo is intercepted.
Step 3: The Gallery and the Full Photo Page
// app/page.tsx
import Image from "next/image";
import Link from "next/link";
import { getPhotos } from "./lib/photos";
export default async function GalleryPage() {
const photos = await getPhotos();
return (
<main className="mx-auto max-w-5xl p-6">
<h1 className="mb-6 text-3xl font-bold">Gallery</h1>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`} scroll={false}>
<Image
src={photo.src}
alt={photo.title}
width={400}
height={300}
className="rounded-lg object-cover"
/>
</Link>
))}
</div>
</main>
);
}
// app/photos/[id]/page.tsx
import Image from "next/image";
import { notFound } from "next/navigation";
import { getPhoto, getPhotos } from "../../lib/photos";
export async function generateStaticParams() {
const photos = await getPhotos();
return photos.map((photo) => ({ id: photo.id }));
}
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
if (!photo) notFound();
return (
<main className="mx-auto max-w-3xl p-6">
<Image
src={photo.src}
alt={photo.title}
width={1200}
height={800}
className="rounded-lg"
/>
<h1 className="mt-4 text-2xl font-bold">{photo.title}</h1>
<p className="text-gray-500">Photo by {photo.author}</p>
</main>
);
}
The full page is a normal dynamic route. For details on dynamic segments, see how to handle dynamic routes in Next.js.
Step 4: The Modal Component
The modal is a Client Component that closes by going back in history, so the back button and the close button behave identically:
// app/@modal/(.)photos/[id]/modal.tsx
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
}
}, []);
function close() {
router.back();
}
return (
<dialog
ref={dialogRef}
onClose={close}
onClick={(event) => {
if (event.target === dialogRef.current) close();
}}
className="w-full max-w-2xl rounded-xl p-0 backdrop:bg-black/60"
>
<div className="p-4">
<button
type="button"
onClick={close}
className="float-right text-sm"
aria-label="Close"
>
Close
</button>
{children}
</div>
</dialog>
);
}
Using the native dialog element with showModal() gives you focus trapping, the Escape key, and an accessible backdrop for free. The onClose handler fires when Escape is pressed, and the onClick check closes the modal when the backdrop is clicked.
Step 5: The Intercepted Route
// app/@modal/(.)photos/[id]/page.tsx
import Image from "next/image";
import { notFound } from "next/navigation";
import { getPhoto } from "../../../lib/photos";
import { Modal } from "./modal";
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
if (!photo) notFound();
return (
<Modal>
<Image
src={photo.src}
alt={photo.title}
width={1200}
height={800}
className="rounded-lg"
/>
<h2 className="mt-3 text-xl font-semibold">{photo.title}</h2>
<p className="text-sm text-gray-500">Photo by {photo.author}</p>
</Modal>
);
}
The intercepted page is still a Server Component. Only the Modal wrapper is a Client Component, and the photo content is passed in as children, so data fetching stays on the server.
Step 6: Closing the Modal on Other Navigations
Parallel slots keep their active state during soft navigation. That means if the modal is open and the user clicks a link to /about, the @modal slot would keep showing the photo, because it has no match for /about and soft navigation preserves the previous state. A catch-all page returning null fixes this:
// app/@modal/[...catchAll]/page.tsx
export default function CatchAll() {
return null;
}
Now any route without a more specific match in @modal renders nothing, and the modal disappears.
How It Behaves
| Action | Result |
|---|---|
| Click a thumbnail in the gallery | URL becomes /photos/2, modal opens over gallery |
| Press Escape, click Close, or press Back | URL returns to /, modal closes |
| Press Forward | Modal reopens |
| Refresh while the modal is open | Full photo page renders, no modal |
Open a shared /photos/2 link | Full photo page renders |
That is the whole point of the pattern: the modal is a real URL, the history stack works naturally, and direct visits get a proper, crawlable page.
A Login Modal Variant
The same structure works for any modal with a standalone page. For a login modal, create app/login/page.tsx for the full page, app/@auth/(.)login/page.tsx wrapping the same form component in a modal, app/@auth/default.tsx returning null, and a catch-all in @auth to close it. Keep the form itself in a shared component so both routes render identical fields, and let your form submit handler redirect after a successful login.
Common Problems and Fixes
- Build fails because a slot has no default.tsx. This is required in Next.js 16. Add
default.tsxto every slot, returningnull, re-exporting the page, or callingnotFound(). - A page works when navigating but returns 404 on refresh. A slot or the implicit
childrenslot has no match on hard navigation. Add the missingdefault.tsx. - The modal does not open; the full page loads instead. The interception marker counts levels wrong. Remember that slots and route groups are not segments:
app/@modal/(.)photosintercepts/photos. - The modal stays open after navigating elsewhere. Soft navigation preserves slot state. Add a
[...catchAll]/page.tsxthat returnsnullin the modal slot. - Changes to intercepting folders are not picked up in development. Restart the dev server after creating or renaming slot and interception folders.
- A slot prop is undefined in the layout. The prop name must match the folder name without the
@. A folder named@modalbecomes themodalprop. - Sensitive data leaks through a conditional slot. Both slots render on the server. Authorize in each slot's page, not only in the layout.
Parallel and Intercepting Routes FAQ
No. Slot folders that start with the at sign are not route segments. A page at app/dashboard/@team/page.tsx renders at /dashboard, and the slot is passed to the dashboard layout as a prop named team.
On a hard navigation, such as a refresh, Next.js cannot recover which page each slot was showing. default.tsx tells it what to render for slots that do not match the current URL. Since Next.js 16 the build fails if a slot has no default.tsx, which prevents surprise 404s in production.
No, and that is intentional. Interception only happens during client-side navigation within the app. A refresh or a direct visit renders the real route, which is why the intercepted URL is shareable and still shows a complete page.
A route group is a folder name in parentheses, such as (marketing), used to organize files without changing the URL. Interception markers always start with dots, such as (.) or (..), and tell Next.js to intercept a route at a relative segment level.
Yes. You can intercept a route inside a normal segment, for example app/feed/(..)photos, to render a different view of the photo within the feed layout. Parallel routes are only needed when you want the intercepted content to render alongside the current page, as in a modal.
Yes. The intercepted page.tsx is a Server Component by default and can fetch data directly. Only the interactive wrapper, such as the modal with its close behavior, needs to be a Client Component.
Conclusion
Parallel routes and intercepting routes solve problems that client state handles poorly. Parallel routes let one layout render several independently loading, independently failing, independently navigable pages. Intercepting routes let a URL open in context during client navigation while still working as a full page on a direct visit. Together, they produce modals that are shareable, refresh-safe, and work naturally with the back and forward buttons.
The rules to remember are short: slots are props, not URL segments; every slot needs a default.tsx, which Next.js 16 now enforces; soft navigation preserves slot state while hard navigation falls back to defaults; interception levels count route segments, not folders; and a catch-all page returning null closes a modal when users navigate away.
Here are some useful references for going deeper on parallel and intercepting routes:
- Next.js Docs: Parallel Routes — slots, default.js, tab groups, and the modal pattern.
- Next.js Docs: Intercepting Routes — the interception conventions and examples.
- Next.js Docs: default.js — how fallbacks for unmatched slots work.
- Vercel Labs: Nextgram example — a complete photo gallery built with parallel and intercepting routes.
- MDN Web Docs: The dialog element — the native modal used in the example.


