Next.js Intercepting Routes for Modals
Build modal patterns in Next.js using intercepting routes. Learn the (.) convention, combining with parallel routes, and handling hard refreshes.
What you'll learn
- ✓What intercepting routes are and why they exist
- ✓The (.), (..), (..)(..), and (...) conventions
- ✓Building a photo modal that preserves the feed underneath
- ✓Combining intercepting routes with parallel route slots
- ✓Handling hard refresh vs. soft navigation gracefully
Prerequisites
- •Working knowledge of the [Next.js App Router](/blog/nextjs-app-router-basics)
- •Familiarity with [parallel routes](/blog/nextjs-parallel-routes-guide)
Intercepting routes let you load a route within the context of the current layout while keeping the existing page visible. The classic example is a photo feed: clicking a photo opens it in a modal overlay without navigating away from the feed. If the user shares the URL or refreshes the page, the full photo page renders instead.
The interception conventions
Next.js uses special folder-name prefixes to intercept navigation at different levels of the route tree:
| Convention | Matches |
|---|---|
(.) | Same level (sibling segment) |
(..) | One level up |
(..)(..) | Two levels up |
(...) | From the app root |
These prefixes tell the router: “When soft-navigating to this route, render my page instead of the real target.”
A minimal example
Consider a feed page that links to individual photo pages:
app/
feed/
page.tsx
(.)photo/[id]/
page.tsx ← intercepted version (modal)
photo/[id]/
page.tsx ← full page version
The (.)photo/[id] folder inside feed/ intercepts navigation to /photo/123 when the user is already on /feed. The (.) prefix means “match a sibling segment at the same level.”
// app/feed/page.tsx
import Link from 'next/link';
const photos = [1, 2, 3, 4, 5];
export default function FeedPage() {
return (
<div className="grid grid-cols-3 gap-2">
{photos.map((id) => (
<Link key={id} href={`/photo/${id}`}>
<img src={`/photos/${id}.jpg`} alt={`Photo ${id}`} />
</Link>
))}
</div>
);
}
// app/feed/(.)photo/[id]/page.tsx
import { Modal } from '@/components/Modal';
export default async function InterceptedPhoto({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<Modal>
<img
src={`/photos/${id}.jpg`}
alt={`Photo ${id}`}
className="w-full rounded"
/>
<p className="mt-2 text-center">Photo #{id}</p>
</Modal>
);
}
// app/photo/[id]/page.tsx
export default async function FullPhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="max-w-2xl mx-auto py-8">
<img
src={`/photos/${id}.jpg`}
alt={`Photo ${id}`}
className="w-full rounded"
/>
<h1 className="text-2xl mt-4">Photo #{id}</h1>
</div>
);
}
When a user clicks a photo link on /feed, the intercepted route renders the modal version. If they refresh the page on /photo/3, the full page version renders.
Combining with parallel routes
The intercepting route by itself replaces the current page. To overlay the modal on top of the feed, combine it with a parallel route slot:
app/
layout.tsx
@modal/
(.)photo/[id]/
page.tsx
default.tsx
feed/
page.tsx
photo/[id]/
page.tsx
// app/layout.tsx
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 ModalDefault() {
return null;
}
The @modal slot renders null by default. When the intercepting route activates, it renders the modal component in the slot. The feed stays in children, creating the overlay effect.
Building the modal component
A reusable modal component that handles closing via the router:
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const onDismiss = useCallback(() => {
router.back();
}, [router]);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss]
);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onDismiss}
>
<div
className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
{children}
<button
onClick={onDismiss}
className="mt-4 text-sm text-gray-500 hover:text-gray-800"
>
Close
</button>
</div>
</div>
);
}
Calling router.back() dismisses the modal and returns to the feed because the interception is a soft navigation that adds a history entry.
The (..) and (…) conventions
When your intercepting route is not at the same level as the target, use (..) to go up one level or (...) to match from the root:
app/
dashboard/
settings/
page.tsx
(..)profile/
page.tsx ← intercepts /dashboard/profile from settings
profile/
page.tsx
The (...) convention matches from the app root, useful when the intercepting route is deeply nested but the target is at the top level:
app/
dashboard/
projects/
[projectId]/
(...)login/
page.tsx ← intercepts /login from deep in the tree
login/
page.tsx
Hard refresh behavior
Intercepting routes only activate during client-side (soft) navigation. When the user:
- Types the URL directly in the browser
- Refreshes the page
- Opens the link in a new tab
- Shares the URL with someone
The intercepting route is bypassed, and the original target route renders normally. This is the key design choice: intercepting routes provide a better UX during in-app navigation while maintaining correct behavior for direct access.
Common pitfalls
Segment level confusion. The (.) prefix matches the same segment level, not the same folder. If your target route is one folder up in the route hierarchy, use (..). Count route segments, not filesystem directories.
Missing default.tsx in the parallel slot. Without a default, navigating to a route that does not have an interception match will throw a 404 for that slot.
Stale modal content after navigation. If you navigate between intercepted routes (e.g., clicking photo 1, then photo 2 from within the modal), make sure your modal component handles key changes. Use the route param as a React key to force re-renders.
When to use intercepting routes
Intercepting routes work well for:
- Photo/media galleries where items open in a lightbox
- Login modals that overlay the current page
- Quick-view panels in e-commerce product listings
- Detail previews in a dashboard without full navigation
If the modal has no meaningful standalone page, use a regular client-side modal instead. Intercepting routes are most valuable when the content should also be accessible as its own URL.
Related articles
- Next.js Next.js Parallel Routes with the @folder Convention
Learn how to render multiple pages simultaneously in the same layout using Next.js parallel routes and the @folder naming convention.
- Next.js Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.
- Next.js Next.js i18n: Complete Internationalization Guide
Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.
- Next.js Next.js Server Actions: Forms and Mutations Guide
Learn how to use Next.js Server Actions for form handling, data mutations, optimistic updates, and error handling with practical examples.