Intercepting Routes
Intercepting routes let you load a route within the current layout during client-side navigation while preserving the full page on direct URL access or refresh. The classic use case is a modal that opens inline but has its own shareable URL.
Recipe
Quick-reference recipe card — copy-paste ready.
Convention Matches
(.)folder Same level (like ./folder)
(..)folder One level up (like ../folder)
(..)(..)folder Two levels up (like ../../folder)
(...)folder Root level (like /folder from anywhere)
app/
├── layout.tsx
├── @modal/
│ ├── default.tsx # Renders nothing when no modal is active
│ └── (.)photo/[id]/page.tsx # Intercepts /photo/:id → renders in modal
├── photo/[id]/
│ └── page.tsx # Full page for /photo/:id (direct access)
└── page.tsx # Gallery page with photo thumbnails
When to reach for this: Photo galleries with modal previews, login modals with /login URLs, item detail previews in a list, or any pattern where a route should show as a modal during navigation but as a full page on direct access.
Working Example
// app/layout.tsx — Root layout with modal slot
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 — Renders nothing when no modal is intercepted
export default function ModalDefault() {
return null;
}// app/@modal/(.)photo/[id]/page.tsx — Intercepted route (shows as modal)
"use client";
import { useRouter } from "next/navigation";
export default function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const router = useRouter();
// In a client component, use React.use() to unwrap params
const { id } = require("react").use(params);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={() => router.back()}
>
<div
className="relative max-h-[90vh] max-w-3xl overflow-hidden rounded-lg bg-white p-4"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => router.back()}
className="absolute right-2 top-2 text-gray-500 hover:text-gray-800"
>
Close
</button>
<img
src={`/photos/${id}.jpg`}
alt={`Photo ${id}`}
className="max-h-[80vh] w-auto"
/>
<p className="mt-2 text-center text-sm text-gray-600">Photo #{id}</p>
</div>
</div>
);
}// app/photo/[id]/page.tsx — Full page (direct URL access or refresh)
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="flex min-h-screen flex-col items-center justify-center p-8">
<img
src={`/photos/${id}.jpg`}
alt={`Photo ${id}`}
className="max-h-[80vh] rounded-lg"
/>
<h1 className="mt-4 text-2xl font-bold">Photo #{id}</h1>
<a href="/" className="mt-4 text-blue-600 hover:underline">
Back to gallery
</a>
</div>
);
}// app/page.tsx — Gallery page with links that trigger interception
import Link from "next/link";
const photos = Array.from({ length: 12 }, (_, i) => ({
id: String(i + 1),
title: `Photo ${i + 1}`,
}));
export default function GalleryPage() {
return (
<div className="p-8">
<h1 className="mb-6 text-3xl font-bold">Photo Gallery</h1>
<div className="grid grid-cols-3 gap-4">
{photos.map((photo) => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<img
src={`/photos/${photo.id}.jpg`}
alt={photo.title}
className="aspect-square rounded-lg object-cover hover:opacity-80"
/>
</Link>
))}
</div>
</div>
);
}Deep Dive
How It Works
- Interception only happens on client-side navigation. When a user clicks a
<Link>, Next.js renders the intercepting route instead of the target route. - Direct URL access or refresh bypasses interception. Typing the URL or refreshing loads the full page version at
app/photo/[id]/page.tsx. - Intercepting routes use parallel route slots. The
@modalfolder is a parallel route. The intercepting route(.)photo/[id]is placed inside it. - The dot convention indicates relative depth.
(.)means same level,(..)means one level up. This is relative to the route segment, not the file system. default.tsxis required in the slot. Without it, the slot renders nothing on non-intercepted routes, and Next.js may throw errors on hard navigation.router.back()dismisses the modal. Since interception uses client-side navigation, going back restores the previous view.- The full page still exists at its URL. Interception does not remove the original route — it shadows it during soft navigation only.
Variations
# Login modal intercepted from any page
app/
├── layout.tsx # Includes @modal slot
├── @modal/
│ ├── default.tsx
│ └── (...)login/page.tsx # Intercepts /login from root level
├── login/
│ └── page.tsx # Full login page (direct access)
└── page.tsx
// app/@modal/(...)login/page.tsx — Login modal
"use client";
import { useRouter } from "next/navigation";
export default function LoginModal() {
const router = useRouter();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-8">
<h2 className="text-xl font-bold">Log In</h2>
<form className="mt-4 space-y-4">
<input type="email" placeholder="Email" className="w-full rounded border p-2" />
<input type="password" placeholder="Password" className="w-full rounded border p-2" />
<button type="submit" className="w-full rounded bg-blue-600 py-2 text-white">
Log In
</button>
</form>
<button onClick={() => router.back()} className="mt-4 text-sm text-gray-500">
Cancel
</button>
</div>
</div>
);
}# Nested interception — item detail in a list
app/
├── feed/
│ ├── layout.tsx
│ ├── @detail/
│ │ ├── default.tsx
│ │ └── (..)post/[id]/page.tsx # Intercepts /post/:id
│ └── page.tsx # Feed list
├── post/[id]/
│ └── page.tsx # Full post page
TypeScript Notes
// Intercepting route pages have the same props as regular pages
interface InterceptedPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
// Layout receiving a parallel route slot
interface LayoutWithModalProps {
children: React.ReactNode;
modal: React.ReactNode; // The @modal slot
}Gotchas
- The dot convention refers to route segments, not file-system directories.
(..)goes up one route segment, which may not correspond to one directory level if route groups are involved. default.tsxmust exist in the slot. Without it, navigating away from the intercepted route and then back can cause errors.- Refreshing the page loads the full route, not the intercepted one. Users who share the URL will see the full page, not the modal — this is by design.
- Route groups
(group)count as a segment for the dot convention. If your intercepting route is inside a route group, you may need an extra(..)level. - Back navigation may not work as expected with complex histories. If the user navigated through multiple intercepted routes,
router.back()pops the last entry, which might be another modal. - Intercepting routes add complexity. If you do not need the shareable-URL-as-full-page behavior, a simple client-side modal state is simpler.
- Parallel route slots must be direct children of a layout. You cannot nest
@modalarbitrarily — it must be a sibling of the layout that renders it.
Alternatives
| Approach | When to Use |
|---|---|
| Client-side modal state | Simple modals without shareable URLs |
Query parameter modal (?modal=photo&id=1) | Shareable state without route interception complexity |
| Parallel Routes without interception | Side-by-side views that are not modals |
Dialog element with useSearchParams | Lightweight modals driven by URL search params |
FAQs
When does route interception happen and when does it not?
- Interception happens only during client-side navigation (clicking a
<Link>) - Direct URL access, page refresh, or shared links bypass interception and load the full page version
What do the dot conventions (.), (..), (..)(..), and (...) mean?
(.)matches the same route level(..)matches one route level up(..)(..)matches two route levels up(...)matches from the root level
Why is default.tsx required in the @modal slot?
Without default.tsx, navigating away from the intercepted route and then back can cause errors. It provides a fallback (typically returning null) when no modal is active.
How do you dismiss an intercepted modal?
Call router.back() from the useRouter hook. Since interception uses client-side navigation, going back restores the previous view.
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
// In the close button:
<button onClick={() => router.back()}>Close</button>Gotcha: Do the dot conventions refer to file-system directories or route segments?
Route segments, not file-system directories. This distinction matters when route groups are involved, because route groups (group) count as a segment for the dot convention and may require an extra (..) level.
Does the intercepting route replace the original route?
No. The original full page still exists at its URL. Interception only shadows it during soft (client-side) navigation. The original renders on direct access or refresh.
What is the relationship between intercepting routes and parallel routes?
Intercepting routes are placed inside parallel route slots (e.g., @modal). The slot is rendered as a prop in the parent layout alongside children, allowing the modal and page to display simultaneously.
What are the TypeScript types for an intercepted route page?
interface InterceptedPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{
[key: string]: string | string[] | undefined;
}>;
}
interface LayoutWithModalProps {
children: React.ReactNode;
modal: React.ReactNode;
}How do you type the params in a Client Component intercepted route?
Use React.use() to unwrap the Promise since you cannot use await in Client Components.
"use client";
import { use } from "react";
export default function Modal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <div>Item {id}</div>;
}Gotcha: What happens with complex navigation histories and multiple intercepted routes?
router.back() pops the last history entry, which might be another modal rather than the non-modal page. Users navigating through multiple intercepted routes may need to go back multiple times.
When should you use a simple client-side modal instead of intercepting routes?
When you do not need a shareable URL for the modal content. Intercepting routes add file-system complexity. If the modal does not need its own URL or full-page fallback, client-side state is simpler.
Can @modal be nested inside another @slot?
No. Parallel route slots must be direct children of a layout directory. You cannot nest @modal inside another slot.
Related
- Parallel Routes — @slot segments that enable interception
- Navigation —
useRouterfor dismissing modals - Dynamic Routes — parameterized segments in intercepted routes
- App Router Basics — file conventions overview