New skills: - next-best-practices: Next.js 15+ RSC, async patterns, self-hosting (vercel-labs) - better-auth-best-practices: Official Better-Auth with Drizzle adapter (better-auth) - verification-before-completion: Evidence-based completion claims (obra/superpowers) - shadcn-ui: Component patterns with Tailwind v4 adaptation note (developer-kit) - writing-skills: TDD methodology for skill authoring (obra/superpowers) README reorganized by category with Mosaic Stack alignment section. Total: 9 skills (4 existing + 5 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.0 KiB
Parallel & Intercepting Routes
Parallel routes render multiple pages in the same layout. Intercepting routes show a different UI when navigating from within your app vs direct URL access. Together they enable modal patterns.
File Structure
app/
├── @modal/ # Parallel route slot
│ ├── default.tsx # Required! Returns null
│ ├── (.)photos/ # Intercepts /photos/*
│ │ └── [id]/
│ │ └── page.tsx # Modal content
│ └── [...]catchall/ # Optional: catch unmatched
│ └── page.tsx
├── photos/
│ └── [id]/
│ └── page.tsx # Full page (direct access)
├── layout.tsx # Renders both children and @modal
└── page.tsx
Step 1: Root Layout with Slot
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
Step 2: Default File (Critical!)
Every parallel route slot MUST have a default.tsx to prevent 404s on hard navigation.
// app/@modal/default.tsx
export default function Default() {
return null;
}
Without this file, refreshing any page will 404 because Next.js can't determine what to render in the @modal slot.
Step 3: Intercepting Route (Modal)
The (.) prefix intercepts routes at the same level.
// app/@modal/(.)photos/[id]/page.tsx
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} />
</Modal>
);
}
Step 4: Full Page (Direct Access)
// app/photos/[id]/page.tsx
export default async function PhotoPage({
params
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="full-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
</div>
);
}
Step 5: Modal Component with Correct Closing
Critical: Use router.back() to close modals, NOT router.push() or <Link>.
// components/modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
// Close on escape key
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
router.back(); // Correct
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [router]);
// Close on overlay click
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
router.back(); // Correct
}
}, [router]);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<button
onClick={() => router.back()} // Correct!
className="absolute top-4 right-4"
>
Close
</button>
{children}
</div>
</div>
);
}
Why NOT router.push('/') or <Link href="/">?
Using push or Link to "close" a modal:
- Adds a new history entry (back button shows modal again)
- Doesn't properly clear the intercepted route
- Can cause the modal to flash or persist unexpectedly
router.back() correctly:
- Removes the intercepted route from history
- Returns to the previous page
- Properly unmounts the modal
Route Matcher Reference
Matchers match route segments, not filesystem paths:
| Matcher | Matches | Example |
|---|---|---|
(.) |
Same level | @modal/(.)photos intercepts /photos |
(..) |
One level up | @modal/(..)settings from /dashboard/@modal intercepts /settings |
(..)(..) |
Two levels up | Rarely used |
(...) |
From root | @modal/(...)photos intercepts /photos from anywhere |
Common mistake: Thinking (..) means "parent folder" - it means "parent route segment".
Handling Hard Navigation
When users directly visit /photos/123 (bookmark, refresh, shared link):
- The intercepting route is bypassed
- The full
photos/[id]/page.tsxrenders - Modal doesn't appear (expected behavior)
If you want the modal to appear on direct access too, you need additional logic:
// app/photos/[id]/page.tsx
import { Modal } from '@/components/modal';
export default async function PhotoPage({ params }) {
const { id } = await params;
const photo = await getPhoto(id);
// Option: Render as modal on direct access too
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
);
}
Common Gotchas
1. Missing default.tsx → 404 on Refresh
Every @slot folder needs a default.tsx that returns null (or appropriate content).
2. Modal Persists After Navigation
You're using router.push() instead of router.back().
3. Nested Parallel Routes Need Defaults Too
If you have @modal inside a route group, each level needs its own default.tsx:
app/
├── (marketing)/
│ ├── @modal/
│ │ └── default.tsx # Needed!
│ └── layout.tsx
└── layout.tsx
4. Intercepted Route Shows Wrong Content
Check your matcher:
(.)photosintercepts/photosfrom the same route level- If your
@modalis inapp/dashboard/@modal, use(.)photosto intercept/dashboard/photos, not/photos
5. TypeScript Errors with params
In Next.js 15+, params is a Promise:
// Correct
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
}
Complete Example: Photo Gallery Modal
app/
├── @modal/
│ ├── default.tsx
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx
├── photos/
│ ├── page.tsx # Gallery grid
│ └── [id]/
│ └── page.tsx # Full photo page
├── layout.tsx
└── page.tsx
Links in the gallery:
// app/photos/page.tsx
import Link from 'next/link';
export default async function Gallery() {
const photos = await getPhotos();
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} />
</Link>
))}
</div>
);
}
Clicking a photo → Modal opens (intercepted) Direct URL → Full page renders Refresh while modal open → Full page renders