Imagine clicking a photo in a feed and seeing it in a modal—but the URL updates to /photo/123. If you share that URL or refresh, the photo loads as a full page. This is the magic of intercepting routes. They let you "intercept" navigation to show overlays or modals while maintaining proper URLs that work with refresh, sharing, and back button navigation. It's the final piece of Next.js's advanced routing puzzle, and it's incredibly powerful for building modern web experiences.
What Are Intercepting Routes?
Intercepting routes allow you to show a route in a different context (like a modal) while keeping the URL updated and maintaining the ability to:
- Share the URL - Links work correctly
- Refresh the page - Shows full page version
- Navigate with back/forward - History works naturally
- Load directly - URL works when visited directly
The Problem They Solve
Traditional modal implementations have issues:
❌ Traditional Modals
- URL doesn't change
- Can't share or bookmark
- Back button closes entire page
- Refresh loses modal state
- Not accessible via direct URL
✅ Intercepting Routes
- URL updates naturally
- Shareable and bookmarkable
- Back button closes modal
- Refresh shows full page
- Works with direct access
Best of Both Worlds
Intercepting routes give you the UX of modals (quick, contextual overlay) with the benefits of proper routing (shareable URLs, browser navigation, refresh support).
The (..) Convention
Intercepting routes use a special convention similar to relative file paths:
| Convention | Matches | Example |
|---|---|---|
(.) | Same level | Like ./file |
(..) | One level up | Like ../file |
(..)(..) | Two levels up | Like ../../file |
(...) | Root segments | From app directory |
📁 Think Like File Paths
The convention mirrors relative paths: (.) is current directory, (..) is parent directory, etc. It's based on route segments, not file structure!
Basic Example: Photo Gallery Modal
Let's build a classic use case—clicking a photo in a feed shows it in a modal:
Photo Gallery with Intercepting Routes
Click from feed shows modal, refresh shows full page
Select a file or folder to see details
Step 1: Create the Full Page Route
First, create the regular route that shows the full page:
// Full page photo view
// URL: /photo/123
export default async function PhotoPage({
params,
}: {
params: { id: string };
}) {
const photo = await fetch(`https://api.example.com/photos/${params.id}`)
.then(r => r.json());
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<a href="/feed" className="text-blue-600 mb-4 inline-block">
← Back to Feed
</a>
<div className="bg-white rounded-lg shadow-lg p-8">
<img
src={photo.url}
alt={photo.title}
className="w-full h-auto rounded-lg mb-6"
/>
<h1 className="text-3xl font-bold mb-4">{photo.title}</h1>
<p className="text-gray-600 mb-4">{photo.description}</p>
<div className="text-sm text-gray-500">
By {photo.author} • {photo.date}
</div>
</div>
</div>
</div>
);
}Step 2: Create the Feed Page
import Link from 'next/link';
export default async function FeedPage() {
const photos = await fetch('https://api.example.com/photos')
.then(r => r.json());
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Photo Feed</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{photos.map((photo: any) => (
<Link
key={photo.id}
href={`/photo/${photo.id}`}
className="group"
>
<div className="aspect-square overflow-hidden rounded-lg">
<img
src={photo.thumbnail}
alt={photo.title}
className="w-full h-full object-cover group-hover:scale-105 transition"
/>
</div>
<h3 className="mt-2 font-semibold">{photo.title}</h3>
</Link>
))}
</div>
</div>
);
}Step 3: Create the Intercepting Route
Now create the intercepting route that shows the modal:
import Modal from '@/components/Modal';
// This intercepts /photo/[id] when navigating from /feed
export default async function PhotoModal({
params,
}: {
params: { id: string };
}) {
const photo = await fetch(`https://api.example.com/photos/${params.id}`)
.then(r => r.json());
return (
<Modal>
<div className="relative">
<img
src={photo.url}
alt={photo.title}
className="w-full h-auto rounded-lg"
/>
<div className="mt-4">
<h2 className="text-2xl font-bold">{photo.title}</h2>
<p className="text-gray-600 mt-2">{photo.description}</p>
</div>
</div>
</Modal>
);
}Step 4: Create the Modal Component
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
export default function Modal({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
const handleClose = () => {
dialogRef.current?.close();
router.back();
};
return (
<dialog
ref={dialogRef}
onClose={handleClose}
className="backdrop:bg-black/50 rounded-lg p-0 max-w-4xl w-full"
>
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-10 h-10 flex items-center justify-center hover:bg-black/70"
>
✕
</button>
{/* Modal content */}
<div className="p-8">
{children}
</div>
</dialog>
);
}How It Works
When navigating from /feed:
- User clicks photo link to
/photo/123 - Next.js finds the intercepting route
(.)photo/[id] - Shows photo in modal overlay
- URL updates to
/photo/123 - Back button closes modal and returns to feed
When refreshing or visiting directly:
- User visits
/photo/123directly - Next.js uses the original
app/photo/[id]/page.tsx - Shows full page photo view
- No interception occurs!
Understanding Route Matching
The matching is based on route segments, not folder structure:
Example 1: Same Level (.)
app/
feed/
page.tsx → /feed
(.)photo/ ← Intercepts routes at same level
[id]/
page.tsx ← Intercepts /photo/[id]
photo/
[id]/
page.tsx → /photo/[id] (original)(.)photo intercepts /photo/[id] because they're at the same level (both are direct children of the root).
Example 2: One Level Up (..)
app/
dashboard/
projects/
page.tsx → /dashboard/projects
(.)new/ ← Intercepts /dashboard/new (same level)
page.tsx
(..)settings/ ← Intercepts /dashboard/settings (one up)
page.tsx
new/
page.tsx → /dashboard/new
settings/
page.tsx → /dashboard/settingsExample 3: Root Level (...)
app/
dashboard/
projects/
page.tsx → /dashboard/projects
(...)login/ ← Intercepts /login (from root)
page.tsx
login/
page.tsx → /loginKey Insight
The convention is based on URL segments, not physical folders. Think about where the route you want to intercept lives in the URL structure.
Complex Example: Multi-Level Interception
Complex Intercepting Routes
Multiple interceptions at different levels
Select a file or folder to see details
Dashboard with New Project Modal
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Modal from '@/components/Modal';
// Intercepts /dashboard/new when navigating from /dashboard/projects
export default function NewProjectModal() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Create project
await fetch('/api/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
// Close modal and refresh
router.back();
router.refresh();
};
return (
<Modal>
<div className="max-w-md">
<h2 className="text-2xl font-bold mb-6">Create New Project</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2">
Project Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2 border rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-2 border rounded-lg"
rows={4}
/>
</div>
<div className="flex gap-4">
<button
type="submit"
className="flex-1 px-6 py-2 bg-blue-600 text-white rounded-lg"
>
Create Project
</button>
<button
type="button"
onClick={() => router.back()}
className="flex-1 px-6 py-2 bg-gray-200 rounded-lg"
>
Cancel
</button>
</div>
</form>
</div>
</Modal>
);
}'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
// Full page version - shown on direct access or refresh
export default function NewProjectPage() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await fetch('/api/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
router.push('/dashboard/projects');
};
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold mb-8">Create New Project</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-semibold mb-2">
Project Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 border rounded-lg text-lg"
placeholder="Enter project name"
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 border rounded-lg text-lg"
rows={6}
placeholder="Describe your project..."
/>
</div>
<div className="flex gap-4">
<button
type="submit"
className="flex-1 px-8 py-3 bg-blue-600 text-white rounded-lg font-semibold"
>
Create Project
</button>
<button
type="button"
onClick={() => router.back()}
className="px-8 py-3 bg-gray-200 rounded-lg font-semibold"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
}Combining with Parallel Routes
Intercepting routes work great with parallel routes for advanced patterns:
app/
@modal/
(.)photo/
[id]/
page.tsx ← Photo modal in slot
default.tsx ← Empty default
layout.tsx ← Receives modal slot
feed/
page.tsx
photo/
[id]/
page.tsx ← Full page photoexport default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal} {/* Modal slot renders on top */}
</body>
</html>
);
}This pattern keeps the modal completely separate from page content—perfect for complex applications!
Common Intercepting Route Patterns
1. Image Galleries
app/
gallery/
page.tsx → Grid of images
(.)image/
[id]/
page.tsx → Modal with image
image/
[id]/
page.tsx → Full page image view2. Product Quick View
app/
shop/
page.tsx → Product grid
(.)product/
[id]/
page.tsx → Quick view modal
product/
[id]/
page.tsx → Full product page3. Login Modal
app/
page.tsx → Homepage
(...)login/
page.tsx → Login modal from anywhere
login/
page.tsx → Full login page4. Comments Overlay
app/
posts/
[id]/
page.tsx → Post detail
(.)comments/
page.tsx → Comments modal
comments/
page.tsx → Full comments pageBest Practices
1. Always Provide Both Routes
Create both the intercepting route (modal) and the original route (full page):
✅ Good:
app/
feed/(.)photo/[id]/page.tsx ← Modal
photo/[id]/page.tsx ← Full page
❌ Bad:
app/
feed/(.)photo/[id]/page.tsx ← Modal only!
(no full page route)2. Keep Shared Logic in Components
Extract shared UI into components to avoid duplication:
// components/PhotoView.tsx
export function PhotoView({ photo }: { photo: Photo }) {
return (
<div>
<img src={photo.url} alt={photo.title} />
<h2>{photo.title}</h2>
<p>{photo.description}</p>
</div>
);
}
// Use in both modal and full page
// app/feed/(.)photo/[id]/page.tsx
import { PhotoView } from '@/components/PhotoView';
export default function PhotoModal({ params }) {
const photo = await fetchPhoto(params.id);
return <Modal><PhotoView photo={photo} /></Modal>;
}
// app/photo/[id]/page.tsx
import { PhotoView } from '@/components/PhotoView';
export default function PhotoPage({ params }) {
const photo = await fetchPhoto(params.id);
return <div className="container"><PhotoView photo={photo} /></div>;
}3. Handle Closing Gracefully
'use client';
import { useRouter } from 'next/navigation';
export default function Modal({ children }) {
const router = useRouter();
const handleClose = () => {
// Go back in history
router.back();
};
// Close on backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
// Close on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, []);
return (
<div onClick={handleBackdropClick}>
{children}
</div>
);
}4. Consider Mobile Experience
On mobile, consider whether modals make sense or if full pages work better:
'use client';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useRouter } from 'next/navigation';
export default function AdaptiveView({ children }) {
const isMobile = useMediaQuery('(max-width: 768px)');
const router = useRouter();
// On mobile, redirect to full page
if (isMobile) {
router.push('/photo/123'); // Redirect to full page
return null;
}
// On desktop, show modal
return <Modal>{children}</Modal>;
}5. Preserve Scroll Position
When closing modal, preserve the user's scroll position in the feed:
// This is handled automatically by Next.js!
// router.back() preserves scroll position by defaultCommon Issues and Solutions
Issue 1: Interception Not Working
Problem: Modal doesn't show, goes directly to full page
Solutions:
- Check the convention - is it
(.),(..), or(...)? - Verify you're navigating from the right place
- Ensure both routes exist (intercepting and original)
- Restart dev server
Issue 2: Modal Shows on Refresh
Problem: Refreshing shows modal instead of full page
Solution: This means you don't have the original route. Create app/photo/[id]/page.tsx for the full page version.
Issue 3: Back Button Doesn't Work
Problem: Clicking back doesn't close modal
Solution: Use router.back() to close modal, not router.push(). The back function respects browser history.
When to Use Intercepting Routes
✅ Use Intercepting Routes When:
- Building image galleries or lightboxes
- Creating quick view modals for products
- Showing forms in overlays (login, sign up, create)
- Displaying content that should be shareable
- You want modal UX with proper URL support
- SEO matters for the modal content
❌ Don't Use Intercepting Routes When:
- Simple confirmations or alerts (use regular modals)
- Content doesn't need its own URL
- The modal is purely temporary UI state
- Adding complexity without clear benefit
Practice Exercise
🎯 Build a Product Gallery
Create an e-commerce product gallery with intercepting routes:
- Create
app/products/page.tsx- product grid - Create
app/products/[id]/page.tsx- full product page - Create
app/products/(.)product/[id]/page.tsx- quick view modal - Add a Modal component with close functionality
- Test navigation, refresh, and direct access
Reusable Modal Component
Use this component for intercepting routes
Output Preview
Key Takeaways
- Intercepting routes show modals with proper URLs - best of both worlds
- Use (..) convention for matching - like relative file paths
- Always provide both routes - intercepting and original
- Refresh shows full page - interception is client-side only
- Perfect for galleries and quick views - shareable modal content
- Works with parallel routes - for complex patterns
- Handle closing gracefully - back button, escape key, backdrop
- Extract shared components - avoid duplicating logic
You've Mastered Advanced Routing!
Congratulations! You've completed the routing fundamentals section and learned every advanced routing pattern in Next.js 15:
What You've Learned
📁 Core Routing
- File-based routing
- Creating pages
- Dynamic routes [slug]
- Catch-all routes [...slug]
🎨 Advanced Patterns
- Route groups (folder)
- Parallel routes @folder
- Intercepting routes (.)
- Modal patterns
With these routing skills, you can build sophisticated applications with complex navigation patterns, multiple layouts, and advanced UI behaviors—all while maintaining clean, SEO-friendly URLs and great user experience.
🚀 What's Next?
Now that you've mastered routing, it's time to explore other essential Next.js concepts like data fetching, rendering strategies, Server Actions, and more. Each concept builds on the routing foundation you've established!