Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Apply Next.js best practices for RSC boundaries, async APIs, routing, metadata, and optimization.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
parallel-routes.md
1# Parallel & Intercepting Routes23Parallel 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.45## File Structure67```8app/9├── @modal/ # Parallel route slot10│ ├── default.tsx # Required! Returns null11│ ├── (.)photos/ # Intercepts /photos/*12│ │ └── [id]/13│ │ └── page.tsx # Modal content14│ └── [...]catchall/ # Optional: catch unmatched15│ └── page.tsx16├── photos/17│ └── [id]/18│ └── page.tsx # Full page (direct access)19├── layout.tsx # Renders both children and @modal20└── page.tsx21```2223## Step 1: Root Layout with Slot2425```tsx26// app/layout.tsx27export default function RootLayout({28children,29modal,30}: {31children: React.ReactNode;32modal: React.ReactNode;33}) {34return (35<html>36<body>37{children}38{modal}39</body>40</html>41);42}43```4445## Step 2: Default File (Critical!)4647**Every parallel route slot MUST have a `default.tsx`** to prevent 404s on hard navigation.4849```tsx50// app/@modal/default.tsx51export default function Default() {52return null;53}54```5556Without this file, refreshing any page will 404 because Next.js can't determine what to render in the `@modal` slot.5758## Step 3: Intercepting Route (Modal)5960The `(.)` prefix intercepts routes at the same level.6162```tsx63// app/@modal/(.)photos/[id]/page.tsx64import { Modal } from '@/components/modal';6566export default async function PhotoModal({67params68}: {69params: Promise<{ id: string }>70}) {71const { id } = await params;72const photo = await getPhoto(id);7374return (75<Modal>76<img src={photo.url} alt={photo.title} />77</Modal>78);79}80```8182## Step 4: Full Page (Direct Access)8384```tsx85// app/photos/[id]/page.tsx86export default async function PhotoPage({87params88}: {89params: Promise<{ id: string }>90}) {91const { id } = await params;92const photo = await getPhoto(id);9394return (95<div className="full-page">96<img src={photo.url} alt={photo.title} />97<h1>{photo.title}</h1>98</div>99);100}101```102103## Step 5: Modal Component with Correct Closing104105**Critical: Use `router.back()` to close modals, NOT `router.push()` or `<Link>`.**106107```tsx108// components/modal.tsx109'use client';110111import { useRouter } from 'next/navigation';112import { useCallback, useEffect, useRef } from 'react';113114export function Modal({ children }: { children: React.ReactNode }) {115const router = useRouter();116const overlayRef = useRef<HTMLDivElement>(null);117118// Close on escape key119useEffect(() => {120function onKeyDown(e: KeyboardEvent) {121if (e.key === 'Escape') {122router.back(); // Correct123}124}125document.addEventListener('keydown', onKeyDown);126return () => document.removeEventListener('keydown', onKeyDown);127}, [router]);128129// Close on overlay click130const handleOverlayClick = useCallback((e: React.MouseEvent) => {131if (e.target === overlayRef.current) {132router.back(); // Correct133}134}, [router]);135136return (137<div138ref={overlayRef}139onClick={handleOverlayClick}140className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"141>142<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">143<button144onClick={() => router.back()} // Correct!145className="absolute top-4 right-4"146>147Close148</button>149{children}150</div>151</div>152);153}154```155156### Why NOT `router.push('/')` or `<Link href="/">`?157158Using `push` or `Link` to "close" a modal:1591. Adds a new history entry (back button shows modal again)1602. Doesn't properly clear the intercepted route1613. Can cause the modal to flash or persist unexpectedly162163`router.back()` correctly:1641. Removes the intercepted route from history1652. Returns to the previous page1663. Properly unmounts the modal167168## Route Matcher Reference169170Matchers match **route segments**, not filesystem paths:171172| Matcher | Matches | Example |173|---------|---------|---------|174| `(.)` | Same level | `@modal/(.)photos` intercepts `/photos` |175| `(..)` | One level up | `@modal/(..)settings` from `/dashboard/@modal` intercepts `/settings` |176| `(..)(..)` | Two levels up | Rarely used |177| `(...)` | From root | `@modal/(...)photos` intercepts `/photos` from anywhere |178179**Common mistake**: Thinking `(..)` means "parent folder" - it means "parent route segment".180181## Handling Hard Navigation182183When users directly visit `/photos/123` (bookmark, refresh, shared link):184- The intercepting route is bypassed185- The full `photos/[id]/page.tsx` renders186- Modal doesn't appear (expected behavior)187188If you want the modal to appear on direct access too, you need additional logic:189190```tsx191// app/photos/[id]/page.tsx192import { Modal } from '@/components/modal';193194export default async function PhotoPage({ params }) {195const { id } = await params;196const photo = await getPhoto(id);197198// Option: Render as modal on direct access too199return (200<Modal>201<img src={photo.url} alt={photo.title} />202</Modal>203);204}205```206207## Common Gotchas208209### 1. Missing `default.tsx` → 404 on Refresh210211Every `@slot` folder needs a `default.tsx` that returns `null` (or appropriate content).212213### 2. Modal Persists After Navigation214215You're using `router.push()` instead of `router.back()`.216217### 3. Nested Parallel Routes Need Defaults Too218219If you have `@modal` inside a route group, each level needs its own `default.tsx`:220221```222app/223├── (marketing)/224│ ├── @modal/225│ │ └── default.tsx # Needed!226│ └── layout.tsx227└── layout.tsx228```229230### 4. Intercepted Route Shows Wrong Content231232Check your matcher:233- `(.)photos` intercepts `/photos` from the same route level234- If your `@modal` is in `app/dashboard/@modal`, use `(.)photos` to intercept `/dashboard/photos`, not `/photos`235236### 5. TypeScript Errors with `params`237238In Next.js 15+, `params` is a Promise:239240```tsx241// Correct242export default async function Page({ params }: { params: Promise<{ id: string }> }) {243const { id } = await params;244}245```246247## Complete Example: Photo Gallery Modal248249```250app/251├── @modal/252│ ├── default.tsx253│ └── (.)photos/254│ └── [id]/255│ └── page.tsx256├── photos/257│ ├── page.tsx # Gallery grid258│ └── [id]/259│ └── page.tsx # Full photo page260├── layout.tsx261└── page.tsx262```263264Links in the gallery:265266```tsx267// app/photos/page.tsx268import Link from 'next/link';269270export default async function Gallery() {271const photos = await getPhotos();272273return (274<div className="grid grid-cols-3 gap-4">275{photos.map(photo => (276<Link key={photo.id} href={`/photos/${photo.id}`}>277<img src={photo.thumbnail} alt={photo.title} />278</Link>279))}280</div>281);282}283```284285Clicking a photo → Modal opens (intercepted)286Direct URL → Full page renders287Refresh while modal open → Full page renders288