Next.js Parallel and Intercepting Routes
Modal patterns, @parallel slots, and (.) conventions — the mental model most devs get wrong.
Parallel and Intercepting Routes are Next.js's answer to the "modal problem": showing content in an overlay while preserving URL shareability and back-button behavior. The mental model is counterintuitive: you're not "opening a modal" — you're rendering two routes simultaneously, with one visually overlaying the other.
The Core Concepts
Parallel Routes: Multiple Pages, One URL
Parallel routes render multiple page components within the same layout. Each "slot" (defined with @folder) is an independent route that can have its own loading states, error boundaries, and nested routes.
app/
├── layout.tsx # Receives slots as props
├── page.tsx # Default children slot
├── @modal/ # Named slot (doesn't affect URL)
│ ├── default.tsx # Fallback when no modal active
│ └── (.)photo/[id]/
│ └── page.tsx # Intercepts /photo/[id]
└── photo/
└── [id]/
└── page.tsx # Direct navigation targetThe @modal folder creates a "slot" that's passed to the parent layout as a prop. The key insight: slots don't create URL segments. @modal/page.tsx doesn't make /modal accessible.
// app/layout.tsx
export default function Layout({
children,
modal, // @modal slot injected as prop
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal} {/* Renders on top when active */}
</body>
</html>
);
}Intercepting Routes: Hijacking Navigation
Intercepting routes "catch" client-side navigations and render alternative content while updating the URL. The (.) convention matches route segments:
(.)— same segment level(..)— one level up(..)(..)— two levels up(...)— from rootapp/directory
Critical: These match route segments, not filesystem paths. Slot folders (@modal) don't count as segments.
// @modal is NOT a segment, so (.) matches /photo directly
app/
├── @modal/
│ └── (.)photo/[id]/page.tsx # Intercepts /photo/[id]
└── photo/
└── [id]/page.tsx # Full page (hard navigation)The Modal Pattern: Step by Step
1. Create the Full Page Route
// app/photo/[id]/page.tsx
// This renders on direct navigation or page refresh
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="full-page-photo">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
<p>{photo.description}</p>
</div>
);
}2. Create the Intercepting Modal Route
// app/@modal/(.)photo/[id]/page.tsx
// This renders on client-side navigation (Link clicks)
import { Modal } from '@/components/modal';
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
</Modal>
);
}3. Add default.tsx for Inactive State
// app/@modal/default.tsx
// Required: renders when modal slot has no matching route
export default function Default() {
return null; // Nothing visible when no modal active
}This is the most common mistake. Without default.tsx, you'll get 404 errors on page refresh or direct navigation.
4. Wire Up the Layout
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}Edge Cases That Break Things
1. The Hard Navigation Problem
Client-side navigation (Link) shows the modal. Direct URL access or refresh shows the full page. This is by design, but catches developers off guard.
// User clicks Link → modal opens, URL = /photo/123
// User refreshes page → full photo page renders
// User shares URL → recipient sees full page, not modal
// This is correct behavior! The modal is an "enhancement"
// for in-app navigation, not the canonical view.2. The default.tsx Requirement
Every parallel route slot needs a default.tsx for unmatched states. Without it, Next.js can't recover slot state after hard navigation:
// ❌ Missing default.tsx causes 404
app/
├── @modal/
│ └── (.)photo/[id]/page.tsx
└── layout.tsx // ← 404 on /about because @modal has no match
// ✓ Add default.tsx
app/
├── @modal/
│ ├── default.tsx // Returns null
│ └── (.)photo/[id]/page.tsx
└── layout.tsx // ← Works on all routes3. Segment Matching Confusion
The (..) convention counts route segments, not folders. Slot folders are invisible:
// Filesystem structure
app/
├── @modal/
│ └── (.)photo/[id]/page.tsx
└── photo/
└── [id]/page.tsx
// Route segment structure (what (.) sees)
/ ← root
└── photo/[id] ← one segment deep
// @modal is NOT a segment!
// So (.) in @modal matches photo at the same level (root)For nested interception:
// Intercept /dashboard/settings from /dashboard/@panel
app/dashboard/
├── @panel/
│ └── (.)settings/page.tsx # (.) works - same level
└── settings/
└── page.tsx
// Intercept /photo/[id] from /gallery/@modal
app/
├── gallery/
│ └── @modal/
│ └── (..)photo/[id]/page.tsx # (..) - one level up
└── photo/
└── [id]/page.tsx4. Modal Closing with router.back()
Using router.back() closes the modal but may not go where you expect:
// User journey:
// /gallery → /gallery (modal: /photo/1) → /gallery (modal: /photo/2)
// router.back() from photo/2:
// Goes to /gallery (modal: /photo/1), NOT /gallery with no modal
// Solution: Use router.push() for predictable closing
'use client';
import { useRouter } from 'next/navigation';
function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const closeModal = () => {
router.back(); // Or router.push('/gallery') for explicit target
};
return (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}5. Soft vs Hard Navigation State
Parallel routes maintain independent state during soft (client-side) navigation. But hard navigation (refresh, direct URL) resets everything:
// Soft navigation preserves slot state
// /dashboard with @analytics showing /visitors
// Navigate to /dashboard/settings
// @analytics STILL shows /visitors (preserved)
// Hard navigation cannot recover state
// Refresh on /dashboard/settings
// @analytics shows default.tsx (state lost)Production Patterns
Login Modal with Fallback Page
app/
├── layout.tsx
├── @auth/
│ ├── default.tsx # null when logged in
│ └── (.)login/page.tsx # Modal version
├── login/
│ └── page.tsx # Full page version
└── page.tsx// app/@auth/(.)login/page.tsx
import { Modal } from '@/components/modal';
import { LoginForm } from '@/components/login-form';
export default function LoginModal() {
return (
<Modal>
<LoginForm />
</Modal>
);
}
// app/login/page.tsx
import { LoginForm } from '@/components/login-form';
export default function LoginPage() {
return (
<div className="login-page">
<LoginForm />
</div>
);
}Conditional Slot Rendering
// app/dashboard/layout.tsx
import { checkUserRole } from '@/lib/auth';
export default function DashboardLayout({
children,
admin,
user,
}: {
children: React.ReactNode;
admin: React.ReactNode;
user: React.ReactNode;
}) {
const role = checkUserRole();
return (
<div>
{children}
{role === 'admin' ? admin : user}
</div>
);
}Debugging Checklist
- 404 on refresh? Add
default.tsxto your slot - Modal not appearing? Check segment matching (
(.)vs(..)) - Wrong content in slot? Verify slot folder name matches layout prop
- State lost on navigation? That's expected for hard navigation
- Both modal and page rendering? Check your layout renders slot correctly
Parallel and Intercepting Routes are powerful but require understanding the dual-render mental model: you're always rendering multiple routes, with interception controlling which version appears based on navigation type.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Related Insights
Explore related edge cases and patterns
Advertisement