Next.js Intercepting Routes: Modal Patterns Done Right
Most modal implementations break the browser's back button and lose state on refresh. Next.js intercepting routes solve both problems through file-system conventions that map URLs to overlays.
Most modal implementations break the browser's back button and lose state on refresh. Teams reach for state management libraries or complex context providers to track modal visibility, only to discover that deep-linking to modal content becomes impossible. The pattern that solves this is Next.js intercepting routes—a file-system convention that maps URLs to overlay behavior while preserving full route semantics.
Understanding Route Interception in Next.js
Route interception allows Next.js to render a route at a different path than its file-system location. When users click a link within the application, the framework intercepts the navigation and shows an overlay. When users refresh or land directly via URL, the same route renders as a full page. This dual-mode behavior eliminates the state synchronization problems that plague traditional modal implementations.
The mechanism works through parallel routes and a naming convention. Parallel routes render multiple page segments simultaneously in the same layout. Intercepting routes use folder names prefixed with (..) notation to specify which parent segment's navigation to intercept. The combination creates shareable, bookmarkable modal states without custom JavaScript.

The Problem: Traditional Modal Implementations vs. Route-Based Modals
Traditional modal patterns store visibility state in React context or URL query parameters. The context approach breaks on refresh because state doesn't persist. The query parameter approach creates URLs like /gallery?modal=photo&id=123 that don't reflect the actual resource being viewed. Both patterns require custom back-button handling that never quite matches native browser behavior.
// Traditional modal pattern - breaks on refresh
'use client'
export default function Gallery() {
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
return (
<>
<div className="grid">
{photos.map(photo => (
<button onClick={() => setSelectedPhoto(photo.id)}>
<img src={photo.thumbnail} />
</button>
))}
</div>
{selectedPhoto && (
<Modal onClose={() => setSelectedPhoto(null)}>
<PhotoDetail id={selectedPhoto} />
</Modal>
)}
</>
)
}The failure mode here is subtle but expensive. Users cannot share a link to a specific photo modal. The back button doesn't work as expected—it navigates away from the gallery entirely instead of closing the modal. Refreshing loses the modal state. Analytics cannot track modal views as distinct page events. Search engines cannot index the modal content.
flowchart TD
A[User clicks photo] --> B{Implementation type}
B -->|Traditional state| C[Store ID in React state]
B -->|Route-based| D[Navigate to /photos/123]
C --> E[Modal opens]
D --> F[Route intercepted]
F --> G[Modal opens over gallery]
E --> H[User refreshes]
D --> I[User refreshes]
H --> J[State lost - back to gallery]
I --> K[Photo shown in modal]
style J stroke:#f00,fill:#fee
Route-based modals treat each modal as a real route with its own URL. When users share /photos/123, recipients land on that specific photo. The back button closes the modal naturally because it's standard browser navigation. Refreshing preserves the modal state because the URL is the source of truth.
Setting Up Intercepting Routes: File Structure and Conventions
The file structure uses parallel routes (folders prefixed with @) and intercepting route conventions. A typical gallery implementation places the photo detail route at /photos/[id] and intercepts it at the gallery level using @modal/(.)photos/[id].
app/
├── layout.tsx // Root layout with modal slot
├── gallery/
│ ├── layout.tsx // Gallery layout with @modal slot
│ ├── page.tsx // Gallery grid
│ └── @modal/
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx // Intercepted photo modal
└── photos/
└── [id]/
└── page.tsx // Full-page photo detailThe (.) notation intercepts routes one segment up. Other notations include (..) for two segments up, (..)(..) for three segments up, and (...) from the app root. The critical distinction is that these conventions define where to intercept from, not what to intercept. The intercepted route at @modal/(.)photos/[id] catches navigation to /photos/[id] when triggered from within /gallery.
The root or gallery layout must accept the modal slot as a prop and render it alongside the main content. This parallel rendering is what creates the overlay effect without JavaScript state management.
// app/gallery/layout.tsx
export default function GalleryLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}Building a Gallery Modal with Parallel and Intercepting Routes
A production gallery modal requires three files: the gallery grid, the intercepted modal version, and the full-page fallback. The gallery grid links to the full photo route using standard <Link> components. Next.js intercepts these clicks and renders the modal version when navigation originates from within the app.
// app/gallery/page.tsx
import Link from 'next/link'
export default async function GalleryPage() {
const photos = await fetchPhotos()
return (
<div className="grid grid-cols-3 gap-4">
{photos.map(photo => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img
src={photo.thumbnail}
alt={photo.title}
className="aspect-square object-cover"
/>
</Link>
))}
</div>
)
}The intercepted route renders the same photo component in a modal wrapper. The implementation can share the photo detail component with the full-page version to avoid duplication. The modal wrapper handles overlay styling and provides a close mechanism that uses router.back() to restore the gallery view.
// app/gallery/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal'
import { PhotoDetail } from '@/components/photo-detail'
export default async function PhotoModal({
params,
}: {
params: { id: string }
}) {
return (
<Modal>
<PhotoDetail id={params.id} />
</Modal>
)
}
// components/modal.tsx
'use client'
import { useRouter } from 'next/navigation'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<div
className="fixed inset-0 bg-black/80 z-50"
onClick={() => router.back()}
>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
}The full-page version at /photos/[id] provides the fallback for direct navigation, refreshes, and social media unfurls. This route should render the photo prominently without the gallery context, functioning as a standalone page with proper metadata.

Handling Navigation States: Soft vs. Hard Navigation
Next.js distinguishes between soft and hard navigation. Soft navigation occurs when users click internal links—the framework performs client-side routing and triggers route interception. Hard navigation happens on page load, refresh, or when users paste URLs directly—these bypass interception and render the target route normally.
This distinction is critical. The intercepting route only activates during soft navigation from specific parent segments. When users bookmark /photos/123 and return later, they get the full-page version at /photos/[id]/page.tsx, not the modal. The framework determines navigation type automatically based on how the route was reached.
flowchart TD
A[User action] --> B{Navigation type}
B -->|Click from /gallery| C[Soft navigation]
B -->|Direct URL / Refresh| D[Hard navigation]
C --> E[Check current location]
E --> F{Interceptor exists?}
F -->|Yes| G[Render @modal version]
F -->|No| H[Render target route]
D --> H
G --> I[Modal overlay shown]
H --> J[Full page shown]
The implication here is that both routes must exist and render correctly in isolation. The intercepted modal version should assume it's overlaying the gallery. The full-page version should assume it's the primary content. Shared components between these routes should accept context through props rather than assuming a specific rendering environment.
Teams often miss that router.back() only works reliably if users arrived via soft navigation. When users land directly on /photos/123 and no history exists, calling router.back() will navigate to the referrer or fail silently. The modal close handler should check for history depth or provide an explicit fallback route.
Advanced Patterns: Nested Modals and Multiple Interceptors
Nested modals emerge when intercepting routes themselves contain links to other interceptable routes. A photo modal might link to a user profile, which should also open as a modal. This requires additional parallel route slots and careful slot composition in parent layouts.
// app/gallery/layout.tsx with multiple slots
export default function GalleryLayout({
children,
photo,
profile,
}: {
children: React.ReactNode
photo: React.ReactNode
profile: React.ReactNode
}) {
return (
<>
{children}
{photo}
{profile}
</>
)
}Multiple interceptors at different segment depths allow modals to open from various locations. A photo might intercept from the gallery at @modal/(.)photos/[id] and from the user profile at @profileModal/(..)(.)photos/[id]. Each interceptor targets navigation from its specific context, creating path-dependent overlay behavior.
The challenge with nested patterns is managing the back-button stack. Each modal should close with one back action, not force users through multiple history entries. This sometimes requires custom history manipulation or skipping certain intermediate states during programmatic navigation.
Common Pitfalls and Production Gotchas
The most common mistake is forgetting that intercepted routes must match the target route's segment structure exactly. If the full route is /photos/[id]/details, the interceptor must be (.)photos/[id]/details, not (.)photos/[id]. Mismatched paths cause the interceptor to never trigger.
flowchart LR
subgraph Broken["Broken: segment mismatch"]
A1["gallery"] --> B1["@modal/(.)photos/[id]"]
B1 -.no match.-> C1["photos/[id]/details"]
style C1 stroke:#f00,fill:#fee
end
subgraph Working["Working: segments match"]
A2["gallery"] --> B2["@modal/(.)photos/[id]/details"]
B2 -.intercepts.-> C2["photos/[id]/details"]
end
Another failure mode occurs when parallel route slots don't have default fallbacks. If the modal slot isn't matched, Next.js renders nothing—leaving a broken layout. Each parallel route folder should include a default.tsx that returns null or an appropriate fallback to handle unmatched cases.
Teams frequently assume intercepted routes don't need proper error boundaries or loading states because they're "just modals." The intercepted route is a full server component that fetches data and renders asynchronously. Without loading UI, modals appear frozen. Without error boundaries, failures crash the entire layout.
The subtle issue with refresh behavior trips up production deployments. When users refresh while a modal is open, the framework performs hard navigation to the full-page route. If that route doesn't exist or throws an error, the user experience breaks. Both versions must be production-ready and fully functional.
Best Practices for Modal Route Patterns
Keep intercepted and full-page versions in sync by extracting the core content component. Both routes should import and render the same <PhotoDetail> or equivalent component, differing only in their layout wrapper. This prevents drift where the modal and full-page versions show different information.
Provide explicit close actions rather than relying solely on overlay clicks. Back-button behavior varies across browsers and devices, especially on mobile where swipe gestures might conflict with overlay dismissal. A visible close button with router.back() creates consistent UX.
Use parallel route slots for modals exclusively—don't mix modal rendering with other parallel route purposes in the same slot. A dedicated @modal slot that only handles intercepted overlays simplifies reasoning about when and why the slot renders content.
Test both soft and hard navigation paths for every intercepted route. Automated tests should verify that clicking from the gallery opens the modal and that pasting the URL directly shows the full page. Many teams test only the modal case and discover broken direct links in production.
That covers the essential patterns for Next.js intercepting routes. Apply these in production and the difference will be immediate—modals that work with the browser instead of fighting it, URLs that accurately represent application state, and user experiences that handle every navigation scenario correctly.