|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/intercepting-routes
$cat docs/next.js-—-intercepting-routes.md
updated Last week·14 min read·published
Next.js — Intercepting Routes
Introduction
Intercepting routes allow you to show a route layout while the browser URL remains on a different route. This enables patterns like showing a modal over the current page without navigating away — the user sees the content inline but the URL can be shared and bookmarked to show the full page.
Interception Syntax
interception-syntax
TEXT
| 1 | // The (..) syntax intercepts one level up |
| 2 | // The (...) syntax intercepts two levels up |
| 3 | // The (.) syntax intercepts the same level |
| 4 | |
| 5 | // Folder structure: |
| 6 | // app/ |
| 7 | // feed/ |
| 8 | // page.tsx — /feed (image grid) |
| 9 | // (..)photo/ |
| 10 | // [id]/ |
| 11 | // page.tsx — intercepts /photo/[id] when navigating from /feed |
| 12 | // photo/ |
| 13 | // [id]/ |
| 14 | // page.tsx — /photo/[id] (full page view) |
| 15 | |
| 16 | // (..) — intercepts one segment above |
| 17 | // app/feed/(..)photo/[id]/page.tsx intercepts /photo/[id] |
| 18 | // when the user is on /feed |
| 19 | |
| 20 | // (...) — intercepts multiple segments |
| 21 | // app/(...)photo/[id]/page.tsx intercepts /photo/[id] |
| 22 | // from anywhere in the app |
| 23 | |
| 24 | // (.) — intercepts same level |
| 25 | // app/feed/(.)photo/[id]/page.tsx intercepts /feed/photo/[id] |
ℹ
info
Use (..) for parent-level interception. The number of dots determines how many path segments to go up. (.) intercepts at the same level.
Modal with Intercepting Routes
app/feed/page.tsx
TSX
| 1 | // app/feed/page.tsx — Image grid (normal page) |
| 2 | import Link from "next/link"; |
| 3 | |
| 4 | interface Photo { |
| 5 | id: string; |
| 6 | title: string; |
| 7 | thumbnail: string; |
| 8 | } |
| 9 | |
| 10 | async function getPhotos(): Promise<Photo[]> { |
| 11 | const res = await fetch("https://api.example.com/photos"); |
| 12 | return res.json(); |
| 13 | } |
| 14 | |
| 15 | export default async function FeedPage() { |
| 16 | const photos = await getPhotos(); |
| 17 | |
| 18 | return ( |
| 19 | <div className="grid grid-cols-3 gap-4 p-4"> |
| 20 | {photos.map((photo) => ( |
| 21 | <Link key={photo.id} href={"/photo/" + photo.id} className="block"> |
| 22 | <img src={photo.thumbnail} alt={photo.title} className="rounded" /> |
| 23 | </Link> |
| 24 | ))} |
| 25 | </div> |
| 26 | ); |
| 27 | } |
app/feed/(..)photo/[id]/page.tsx
TSX
| 1 | // app/feed/(..)photo/[id]/page.tsx — Intercepted modal view |
| 2 | // This renders when navigating from /feed to /photo/[id] |
| 3 | // The URL changes to /photo/[id] but the feed page stays visible |
| 4 | |
| 5 | import { db } from "@/lib/database"; |
| 6 | import { CloseButton } from "./close-button"; |
| 7 | |
| 8 | export default async function PhotoModal({ |
| 9 | params, |
| 10 | }: { |
| 11 | params: Promise<{ id: string }>; |
| 12 | }) { |
| 13 | const { id } = await params; |
| 14 | const photo = await db.photo.findUnique({ where: { id } }); |
| 15 | |
| 16 | return ( |
| 17 | <div className="fixed inset-0 z-50 flex items-center justify-center"> |
| 18 | {/* Backdrop — returns to feed */} |
| 19 | <div className="absolute inset-0 bg-black/80" /> |
| 20 | |
| 21 | {/* Modal content */} |
| 22 | <div className="relative z-10 max-w-2xl bg-gray-900 rounded-lg overflow-hidden"> |
| 23 | <CloseButton /> |
| 24 | <img src={photo.url} alt={photo.title} className="w-full" /> |
| 25 | <div className="p-4"> |
| 26 | <h2 className="text-lg font-bold">{photo.title}</h2> |
| 27 | <p className="text-sm text-gray-400">{photo.description}</p> |
| 28 | </div> |
| 29 | </div> |
| 30 | </div> |
| 31 | ); |
| 32 | } |
app/feed/(..)photo/[id]/close-button.tsx
TSX
| 1 | // app/feed/(..)photo/[id]/close-button.tsx |
| 2 | "use client"; |
| 3 | |
| 4 | import { useRouter } from "next/navigation"; |
| 5 | |
| 6 | export function CloseButton() { |
| 7 | const router = useRouter(); |
| 8 | |
| 9 | return ( |
| 10 | <button |
| 11 | onClick={() => router.back()} |
| 12 | className="absolute top-2 right-2 z-20 p-2 bg-black/50 rounded-full text-white" |
| 13 | > |
| 14 | Close |
| 15 | </button> |
| 16 | ); |
| 17 | } |
app/photo/[id]/page.tsx
TSX
| 1 | // app/photo/[id]/page.tsx — Full page view |
| 2 | // This renders when the user navigates directly to /photo/[id] |
| 3 | // or refreshes the page while on the intercepted route |
| 4 | |
| 5 | import { db } from "@/lib/database"; |
| 6 | |
| 7 | export default async function PhotoPage({ |
| 8 | params, |
| 9 | }: { |
| 10 | params: Promise<{ id: string }>; |
| 11 | }) { |
| 12 | const { id } = await params; |
| 13 | const photo = await db.photo.findUnique({ where: { id } }); |
| 14 | |
| 15 | return ( |
| 16 | <div className="min-h-screen bg-black flex items-center justify-center"> |
| 17 | <div className="max-w-3xl"> |
| 18 | <img src={photo.url} alt={photo.title} className="w-full rounded-lg" /> |
| 19 | <h1 className="text-2xl font-bold mt-4">{photo.title}</h1> |
| 20 | <p className="text-gray-400 mt-2">{photo.description}</p> |
| 21 | </div> |
| 22 | </div> |
| 23 | ); |
| 24 | } |
Login Intercepting Pattern
login-pattern.tsx
TSX
| 1 | // Show login as modal on protected pages, full page when accessed directly |
| 2 | |
| 3 | // app/login/page.tsx — Full login page (direct navigation) |
| 4 | export default function LoginPage() { |
| 5 | return ( |
| 6 | <div className="min-h-screen flex items-center justify-center"> |
| 7 | <div className="w-96 p-8 bg-gray-900 rounded-lg"> |
| 8 | <h1 className="text-xl font-bold mb-4">Sign In</h1> |
| 9 | <LoginForm /> |
| 10 | </div> |
| 11 | </div> |
| 12 | ); |
| 13 | } |
| 14 | |
| 15 | // app/(.)login/page.tsx — Intercepted modal login |
| 16 | export default function LoginModal() { |
| 17 | return ( |
| 18 | <div className="fixed inset-0 z-50 flex items-center justify-center"> |
| 19 | <div className="absolute inset-0 bg-black/80" /> |
| 20 | <div className="relative z-10 w-96 p-8 bg-gray-900 rounded-lg"> |
| 21 | <h1 className="text-xl font-bold mb-4">Sign In</h1> |
| 22 | <LoginForm /> |
| 23 | </div> |
| 24 | </div> |
| 25 | ); |
| 26 | } |
| 27 | |
| 28 | // app/dashboard/page.tsx — Protected page links to login |
| 29 | import Link from "next/link"; |
| 30 | |
| 31 | export default function Dashboard() { |
| 32 | return ( |
| 33 | <div> |
| 34 | <h1>Dashboard</h1> |
| 35 | <p>Please sign in to continue.</p> |
| 36 | {/* Clicking this shows login as modal (intercepted) */} |
| 37 | {/* URL changes to /login but dashboard stays visible */} |
| 38 | <Link href="/login">Sign In</Link> |
| 39 | </div> |
| 40 | ); |
| 41 | } |
✓
best practice
Intercepting routes are ideal for modals over existing content. The URL is shareable and works on page refresh (shows the full page version). This is a significant improvement over JavaScript-only modal solutions.
Parallel Routes Integration
parallel-integration.tsx
TSX
| 1 | // Combining intercepting routes with parallel routes for advanced layouts |
| 2 | |
| 3 | // app/layout.tsx |
| 4 | export default function Layout({ |
| 5 | children, |
| 6 | modal, |
| 7 | }: { |
| 8 | children: React.ReactNode; |
| 9 | modal: React.ReactNode; |
| 10 | }) { |
| 11 | return ( |
| 12 | <div> |
| 13 | {children} |
| 14 | {modal} |
| 15 | </div> |
| 16 | ); |
| 17 | } |
| 18 | |
| 19 | // app/@modal/default.tsx — empty default so it doesn't render when not intercepted |
| 20 | export default function Default() { |
| 21 | return null; |
| 22 | } |
| 23 | |
| 24 | // app/@modal/(.)photo/[id]/page.tsx — intercepted modal |
| 25 | export default async function PhotoModal({ |
| 26 | params, |
| 27 | }: { |
| 28 | params: Promise<{ id: string }>; |
| 29 | }) { |
| 30 | const { id } = await params; |
| 31 | return ( |
| 32 | <div className="fixed inset-0 z-50 flex items-center justify-center"> |
| 33 | <div className="absolute inset-0 bg-black/80" /> |
| 34 | <div className="relative z-10"> |
| 35 | <img src={"/api/photo/" + id} alt="Photo" /> |
| 36 | </div> |
| 37 | </div> |
| 38 | ); |
| 39 | } |
| 40 | |
| 41 | // The @modal slot renders null by default (default.tsx) |
| 42 | // When navigating to /photo/[id] from a page that has interception, |
| 43 | // the modal slot renders the intercepted content instead |
$Blueprint — Engineering Documentation·Section ID: NEXT-INTR·Revision: 1.0