Parallel Routes and Intercepting Routes: A Practical Guide
Parallel routing is like multiple monitors—each screen operates independently but is interconnected; intercept routing is like a package pickup point—it intercepts packages along the way, processes them, and then continues delivery.
1. What You'll Learn
- Parallel Routes: Rendering multiple independent views from a single URL
- How to Use the
@modal,@sidebar, and@teamNaming Slots - Intercepting Routes: Matching rules for
(.),(..), and(...) - Modal + Parallel Routing Combination: URL-Driven Modals
- Conditional rendering: Render different dashboard views based on the user's role
2. A Product Manager’s True Story
(1) Pain Point: Poor pop-up experience in the feed
Alice is developing an image browsing feature for a content platform and has encountered some user experience issues:
"When a user tapped on an image in the Feed, we displayed a full-screen modal, and the URL changed to
/photos/123. As a result, when the user tried to return to the Feed by pressing the browser's back button, they were logged out of the entire app. The user complained, 'I just wanted to look at a picture—why can't I go back to the home page?'"
Issues Analyzed by Alice:
| Issue | Impact | User Rating |
|---|---|---|
| Modal cannot be refreshed independently | Refreshing the modal causes the entire page to refresh | 2/5 |
| Back button exits the app | User flow disruption | 1.5/5 |
| Sidebar and main content are out of sync | Navigation is confusing | 2.5/5 |
| Team View and Dashboard Are Separate | Low Management Efficiency | 3/5 |
(2) The Parallel + Intercepting Routes Solution
Use
@modalto display a pop-up window for parallel routing +(.)phototo intercept image navigation in the feed stream.
src/app/
├── layout.tsx # Main Layout:@children + @modal
└── (feed)/
├── layout.tsx # Feed Layout:@children + @sidebar
├── page.tsx # Feed Stream Home Page
└── photos/
├── [id]/
│ └── page.tsx # Full page: /photos/123
└── (.)[id]/
└── page.tsx # Intercept: open as a modal in the Feed
(3) Revenue
| Dimension | Before (Standard Routing) | After (Parallel + Intercepting) |
|---|---|---|
| Image Browsing Experience | Full-page redirect, interrupts browsing | Pop-up preview, stays on the current page |
| Back Button Behavior | Exit the app | Close the modal and return to the Feed |
| Page can be refreshed | Not supported | Refreshes to display the entire page; renders normally |
| Sidebar is independent | Not independent | @sidebar Renders independently, without affecting the main content |
3. Parallel Routes
(1) Concepts and Usage
graph TB
subgraph "URL: /dashboard"
A[layout.tsx] --> B[children<br/>Main Content]
A --> C[@modal<br/>Pop-up Slot]
A --> D[@sidebar<br/>Sidebar Slot]
A --> E[@team<br/>Team Slots]
end
subgraph "Rendering Results"
F[Main Content Area] & G[Pop-up Area] & H[Sidebar Area] & I[Team Area]
end
style A fill:#cce5ff
style B fill:#d4edda
| Slot Name | Directory Prefix | URL Impact | Use Case |
|---|---|---|---|
@children |
None (default) | Standard URL | Main page content |
@modal |
@modal/ |
None | Pop-ups, dialog boxes |
@sidebar |
@sidebar/ |
None | Sidebar Panel |
@team |
@team/ |
None | Team View |
▶ Example: Basic Parallel Routes
// ============================================
// Parallel Routes Basics:Dashboard Multi-slot layout
// ============================================
// src/app/(dashboard)/layout.tsx — Parallel Routing Layout
export default function DashboardLayout({
children,
modal,
sidebar,
team,
}: {
children: React.ReactNode;
modal: React.ReactNode;
sidebar: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="flex h-screen">
{/* Main Content Area */}
<main className="flex-1 p-8 overflow-auto">
{children}
</main>
{/* Sidebar Slot(Independent Parallel Rendering) */}
<aside className="w-72 bg-gray-50 p-4 border-l">
{sidebar}
</aside>
{/* Team Slots(Independent Parallel Rendering) */}
<aside className="w-64 bg-gray-900 text-white p-4">
{team}
</aside>
{/* Modal Slot (Conditional Rendering) */}
{modal}
</div>
);
}
// src/app/(dashboard)/@sidebar/default.tsx — Default State of the Sidebar
export default function SidebarDefault() {
return (
<div>
<h3 className="font-bold text-lg mb-4">Sidebar</h3>
<div className="space-y-2">
<div className="p-3 bg-white rounded shadow-sm">
<p className="font-medium">Recent Activity</p>
<p className="text-sm text-gray-500">No recent activity</p>
</div>
<div className="p-3 bg-white rounded shadow-sm">
<p className="font-medium">Notifications</p>
<p className="text-sm text-gray-500">3 unread</p>
</div>
</div>
</div>
);
}
// src/app/(dashboard)/@team/default.tsx — Default Team Slot Status
export default function TeamDefault() {
return (
<div className="p-4">
<h3 className="font-bold mb-4">Team</h3>
<div className="space-y-3">
{["Alice", "Bob", "Charlie", "Diana"].map(name => (
<div key={name} className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm">
{name[0]}
</div>
<span className="text-sm">{name}</span>
</div>
))}
</div>
</div>
);
}
Output:
Visit /dashboard:
┌──────────────────────┬──────────┬──────────┐
│ │ │ │
│ Main Content Area │ Sidebar │ Team │
│ Dashboard │ Recent │ Alice │
│ Welcome back! │ Activity │ Bob │
│ │ Notif(3) │ Charlie │
│ │ │ Diana │
└──────────────────────┴──────────┴──────────┘
Three regions rendered independently,Do not affect each other
(2) default.tsx — Required default state
Each @slot directory must contain default.tsx, which is displayed when no matching route is found.
graph TB
A[User Navigation] --> B{Current URL<br/>matches slot route?}
B -->|Matches| C[Show slot's page.tsx]
B -->|Mismatch| D[Show slot's default.tsx]
style C fill:#d4edda
style D fill:#f8d7da
▶ Example: The Importance of default.tsx
// ============================================
// None default.tsx It will happen 404
// Each @slot All of them must be present default.tsx
// ============================================
// src/app/@modal/default.tsx — Modal Hidden by default
export default function ModalDefault() {
return null; // None Modal It does not exaggerate anything
}
// src/app/(dashboard)/@sidebar/default.tsx
export default function SidebarDefault() {
return (
<div className="p-4">
<h3 className="font-bold text-sm text-gray-500 uppercase">
Quick Links
</h3>
<nav className="mt-3 space-y-2">
<a href="/dashboard" className="block text-blue-600">Dashboard</a>
<a href="/dashboard/projects" className="block text-blue-600">Projects</a>
<a href="/dashboard/settings" className="block text-blue-600">Settings</a>
</nav>
</div>
);
}
Output:
Visit /dashboard:
- @modal No matching route found → Show ModalDefault (= null,Do not render)
- @sidebar No matching route found → Show SidebarDefault(Quick Links Navigation)
- @team No matching route found → Show TeamDefault(List of Team Members)
Visit /dashboard/photos/1(Assumption @modal Matches found):
- @modal matches → Display the pop-up window content
- Other slot Display each one's default.tsx
4. Intercepting Routes
(1) Interception Matching Rules
graph TB
A[Currently in Feed stream] --> B{Click the image link}
B --> C[Intercept (.)photo]
C --> D[Display a pop-up in the Feed]
D --> E[The user refreshes the page]
E --> F[Bypass Block<br/>Show Entire Page]
style C fill:#cce5ff
style D fill:#d4edda
style F fill:#f8d7da
| Syntax | Matching Level | Example |
|---|---|---|
(.) |
Same Class | feed/photos/(.)[id] Intercept feed/photos/[id] |
(..) |
Back | feed/(..)photos/[id] Intercept photos/[id] |
(..)(..) |
Two levels up | feed/(..)(..)photos/[id] Intercept at the root level photos/[id] |
(...) |
Root level | feed/(...)photos/[id] Intercept app/photos/[id] |
▶ Example: Blocking Image Browsing
// ============================================
// Intercepting Route + Parallel Route
// Feed Click the image below → Display a pop-up window on the current page
// ============================================
// Directory Structure:
// app/
// layout.tsx # Root Layout (with @modal)
// (feed)/
// page.tsx # Feed stream
// photos/
// [id]/page.tsx # Full page: /photos/1
// @modal/
// default.tsx # No modal
// (.)photos/
// [id]/page.tsx # Intercept: display a popup in the Feed
// src/app/layout.tsx — Root Layout (with modal slot)
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{modal}
</body>
</html>
);
}
// src/app/(feed)/page.tsx — Feed Streaming Page
import Link from "next/link";
export default function FeedPage() {
const photos = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
url: `https://picsum.photos/seed/${i + 1}/300/300`,
title: `Photo ${i + 1}`,
}));
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Photo Feed</h1>
<div className="grid grid-cols-3 gap-4">
{photos.map(photo => (
<Link
key={photo.id}
href={`/photos/${photo.id}`}
className="block overflow-hidden rounded-lg hover:opacity-90 transition-opacity"
>
<img
src={photo.url}
alt={photo.title}
className="w-full h-64 object-cover"
/>
<p className="mt-2 text-sm font-medium text-center">{photo.title}</p>
</Link>
))}
</div>
</div>
);
}
// ============================================
// Intercept Routes: Open a modal popup in the Feed
// Documents:src/app/@modal/(.)photos/[id]/page.tsx
// ============================================
'use client';
import { useRouter } from "next/navigation";
export default function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const router = useRouter();
const { id } = params;
return (
// Background Mask
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onClick={() => router.back()}
>
{/* Modal Content — Click the background to close */}
<div
className="bg-white rounded-2xl overflow-hidden max-w-2xl w-full mx-4"
onClick={e => e.stopPropagation()}
>
<img
src={`https://picsum.photos/seed/${id}/800/600`}
alt={`Photo ${id}`}
className="w-full h-auto"
/>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Photo #{id}</h2>
<p className="text-gray-500 text-sm mt-1">
Captured by photographer
</p>
</div>
<button
onClick={() => router.back()}
className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center hover:bg-gray-200"
>
✕
</button>
</div>
<div className="flex gap-2 mt-4">
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">
Nature
</span>
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">
Landscape
</span>
<span className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">
HD
</span>
</div>
</div>
</div>
</div>
);
}
// ============================================
// Full page: Display the full page when accessed independently
// Documents:src/app/photos/[id]/page.tsx
// ============================================
import Link from "next/link";
export default function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = params;
return (
<div className="max-w-4xl mx-auto p-8">
<Link
href="/"
className="text-blue-600 hover:underline mb-4 inline-block"
>
← Back to Feed
</Link>
<img
src={`https://picsum.photos/seed/${id}/1200/800`}
alt={`Photo ${id}`}
className="w-full rounded-lg"
/>
<div className="mt-6">
<h1 className="text-3xl font-bold">Photo #{id}</h1>
<p className="text-gray-500 mt-2">
Full page view of photo {id}. This page is directly accessible
and works even without JavaScript.
</p>
<div className="flex gap-4 mt-6">
<Link
href={`/photos/${Number(id) - 1}`}
className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
>
← Previous
</Link>
<Link
href={`/photos/${Number(id) + 1}`}
className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Next →
</Link>
</div>
</div>
</div>
);
}
Output:
Scene 1: Click an image from the Feed
- Currently in / Page(Photo Feed Grid)
- Click on the 1 Upload a picture
- Display a larger image in a pop-up window(The address bar changes to /photos/1)
- Click the background mask → router.back() → Back to Feed stream
- Click the browser's back button → Close Pop-up → Feed The flow remains unchanged
Scene 2:Direct Access /photos/1
- Skip Intercepted Routes
- Show Full Page (full-width large image + Previous/Next buttons)
- Can be refreshed directly、Share Link
Scene 3:Refresh the pop-up page
- Popup window in the Feed → Press F5 to refresh
- Interception rules are not taking effect(Not from Feed Navigation)
- Show full text /photos/1 Page
(2) Multi-level Interception
▶ Example: Multi-level Interception Routing
// ============================================
// Example of Multi-Level Interception Routing
// Directory Structure:
// app/
// photos/
// [id]/page.tsx → /photos/1(Full Page)
// (feed)/
// page.tsx → / (Feed stream)
// categories/
// [cat]/page.tsx → /categories/nature(Category Page)
// (..)(..)photos/
// [id]/page.tsx → Intercept /photos/1 → Pop-up on the Categories Page
// ============================================
// Explanation of Matching Rules:
// (feed)/categories/[cat] levels = app/(feed)/categories/[cat]
// Objective: app/photos/[id] levels = app/photos/[id]
// You need to go up two levels to match → (..)(..)
// app/(feed)/categories/(..)(..)photos/[id]/page.tsx
'use client';
import { useRouter } from "next/navigation";
export default function CategoryPhotoModal({ params }) {
const router = useRouter();
return (
<div
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center"
onClick={() => router.back()}
>
<div onClick={e => e.stopPropagation()} className="max-w-lg">
<img
src={`https://picsum.photos/seed/${params.id}/600/400`}
alt=""
className="rounded-lg"
/>
<button
onClick={() => router.back()}
className="mt-2 px-4 py-2 bg-white rounded"
>
Close
</button>
</div>
</div>
);
}
Output:
From /categories/nature, click on the image:
1. Display a larger image in a pop-up window(Address bar /photos/1)
2. Click Close or background → router.back() → Back to /categories/nature
3. Direct Access /photos/1 → Show Full Page(No pop-ups)
4. The pop-up window on the category page is working properly
5. Modal + Parallel Route Combination Model
(1) Architecture Design
graph TB
subgraph "URL-Driven Modal Popup"
A[User Actions] --> B{Navigate to /photos/1}
B --> C[In the Feed?]
C -->|Yes| D[@modal slot<br/>Match and Intercept Routes]
C -->|No| E[Direct Access<br/>Show Full Page]
D --> F[Pop-up Display<br/>Address Bar Update]
F --> G[User Logout]
G --> H[router.back()]
H --> I[Back to Feed<br/>Modal Disappear]
end
style D fill:#cce5ff
style E fill:#d4edda
style G fill:#f8d7da
| User Action | URL Change | Modal State | Page State |
|---|---|---|---|
| Click the Feed Image | / → /photos/1 |
Show Pop-up | Keep Feed in the Background |
| Close Pop-up | /photos/1 → / |
Hide | Feed Unchanged |
Refresh /photos/1 |
/photos/1 No changes |
No pop-ups | Show full page |
Share /photos/1 |
Shareable | No pop-ups | Recipients see the full page |
▶ Example: Complete Modal
// ============================================
// Complete Modal + Parallel Route + Intercepting Route
// Implement a"Refreshable、Shareable、Can be undone"The pop-up system
// ============================================
// src/app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{modal}
</body>
</html>
);
}
// src/app/@modal/default.tsx
export default function Default() {
return null;
}
// src/app/@modal/(.)photos/[id]/page.tsx — Block Pop-ups
'use client';
import { useRouter } from "next/navigation";
export default function PhotoModal({ params }) {
const router = useRouter();
return (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
onClick={() => router.back()}
>
<div
className="bg-white rounded-xl max-w-3xl w-full mx-4 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex justify-end p-2">
<button
onClick={() => router.back()}
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded-full"
>
✕
</button>
</div>
<img
src={`https://picsum.photos/seed/${params.id}/800/600`}
alt=""
className="w-full"
/>
<div className="p-6">
<h2 className="text-2xl font-bold">Photo #{params.id}</h2>
<div className="flex gap-4 mt-4">
<a
href={`/photos/${params.id}`}
className="text-sm text-blue-600 hover:underline"
onClick={() => router.push(`/photos/${params.id}`)}
>
Open in full page →
</a>
</div>
</div>
</div>
</div>
);
}
// src/app/(feed)/page.tsx — Feed stream
import Link from "next/link";
export default function Feed() {
return (
<div className="max-w-6xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Photo Gallery</h1>
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map(id => (
<Link
key={id}
href={`/photos/${id}`}
className="block group"
>
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
<img
src={`https://picsum.photos/seed/${id}/400/400`}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
</div>
</Link>
))}
</div>
</div>
);
}
Output:
User Flow:
1. Visit / → See 8 Image Grid
2. Click on the image #3 → Display a larger image in a pop-up window(URL: /photos/3)
3. Click the top-right corner ✕ → Close Pop-up(URL: /)
4. Click image #5 again → Popup (URL: /photos/5)
5. Click the browser's Back button → Close Pop-up(URL: /)
6. Press the Back button again → Exit the app(Normal Behavior)
Refresh Scene:
1. While the pop-up is open, press F5 → Refresh the entire page
2. Interception rules are not taking effect → Show /photos/3 Full Page
3. The page is functioning properly.,There is a navigation link to go back
Sharing Scenarios:
1. Copy /photos/3 URL Send to a friend
2. Friend, open this → View the full page(Not a pop-up)
3. Page SEO Normal,All content is indexable
6. Conditional Rendering of the Dashboard
(1) Render different views based on the role
graph TB
A[Dashboard Layout] --> B{User Roles}
B -->|admin| C[@admin Panel]
B -->|editor| D[@editor Panel]
B -->|viewer| E[@viewer Panel]
style A fill:#cce5ff
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
▶ Example: Role-Driven Dashboard
// ============================================
// Conditional Rendering:Displays differently depending on the character Dashboard
// ============================================
// src/app/(dashboard)/layout.tsx — Role Routing Layout
export default function DashboardLayout({
children,
admin,
editor,
viewer,
}: {
children: React.ReactNode;
admin: React.ReactNode;
editor: React.ReactNode;
viewer: React.ReactNode;
}) {
// Simulate from Cookie/Session Get a Character
const role = "admin";
return (
<div className="flex h-screen">
{/* Main Content */}
<main className="flex-1 p-8">
{children}
</main>
{/* Render different slots based on the character */}
<aside className="w-80 border-l p-4">
{role === "admin" && admin}
{role === "editor" && editor}
{role === "viewer" && viewer}
</aside>
</div>
);
}
// src/app/(dashboard)/@admin/default.tsx — Administrator Panel
export default function AdminPanel() {
return (
<div className="space-y-4">
<h3 className="font-bold text-lg">Admin Controls</h3>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="font-medium text-red-700">System Health</p>
<div className="mt-2 space-y-2">
<div className="flex justify-between text-sm">
<span>CPU Usage</span>
<span className="text-green-600">45%</span>
</div>
<div className="flex justify-between text-sm">
<span>Memory</span>
<span className="text-yellow-600">72%</span>
</div>
<div className="flex justify-between text-sm">
<span>Active Users</span>
<span className="text-blue-600">1,234</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg border p-4">
<p className="font-medium">Pending Approvals</p>
<p className="text-2xl font-bold text-orange-600 mt-2">12</p>
</div>
<button className="w-full p-2 bg-blue-600 text-white rounded-lg">
View All Settings
</button>
</div>
);
}
// src/app/(dashboard)/@editor/default.tsx — Editor Panel
export default function EditorPanel() {
return (
<div className="space-y-4">
<h3 className="font-bold text-lg">Editor Tools</h3>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="font-medium text-blue-700">Draft Count</p>
<p className="text-3xl font-bold mt-2">8</p>
</div>
<div className="bg-white rounded-lg border p-4">
<p className="font-medium">Recent Edits</p>
<div className="mt-2 space-y-2 text-sm">
<p>• Updated homepage hero</p>
<p>• Fixed typo in about page</p>
<p>• Added new blog post</p>
</div>
</div>
<button className="w-full p-2 bg-green-600 text-white rounded-lg">
Create New Post
</button>
</div>
);
}
// src/app/(dashboard)/@viewer/default.tsx — Viewer Panel
export default function ViewerPanel() {
return (
<div className="space-y-4">
<h3 className="font-bold text-lg">Overview</h3>
<div className="bg-gray-50 border rounded-lg p-4">
<p className="font-medium">Your Dashboard</p>
<p className="text-sm text-gray-500 mt-2">
You have read-only access. Contact admin for editing permissions.
</p>
</div>
<div className="bg-white border rounded-lg p-4">
<p className="font-medium">Quick Links</p>
<div className="mt-2 space-y-2 text-sm">
<a href="/docs" className="block text-blue-600">Documentation</a>
<a href="/reports" className="block text-blue-600">Reports</a>
<a href="/help" className="block text-blue-600">Help Center</a>
</div>
</div>
</div>
);
}
Output:
Administrator(role = "admin"):
┌────────────────────────────┬──────────────────────┐
│ │ Admin Controls │
│ Dashboard │ ┌────────────────┐ │
│ Welcome back, Alice! │ │ CPU: 45% ✅ │ │
│ │ │ Memory: 72% ⚠️ │ │
│ Project Summary │ │ Users: 1,234 │ │
│ 12 Active Projects │ └────────────────┘ │
│ 48 Pending Tasks │ Pending Approvals │
│ 8 Team Members │ 12 │
│ │ [View All Settings] │
└────────────────────────────┴──────────────────────┘
Editor(role = "editor"):
┌────────────────────────────┬──────────────────────┐
│ │ Editor Tools │
│ Dashboard │ Draft Count: 8 │
│ Welcome back, Bob! │ Recent Edits: │
│ │ • Updated homepage │
│ My Projects │ • Fixed typo │
│ 5 Active Projects │ • Added blog post │
│ │ [Create New Post] │
└────────────────────────────┴──────────────────────┘
Viewers(role = "viewer"):
┌────────────────────────────┬──────────────────────┐
│ │ Overview │
│ Dashboard │ Read-only access │
│ Welcome, Charlie! │ Quick Links: │
│ │ • Documentation │
│ Team Activity │ • Reports │
│ 10 team members online │ • Help Center │
│ │ │
└────────────────────────────┴──────────────────────┘
7. Complete Example: Feed + Modal + Conditional Dashboard
// ============================================
// Comprehensive Example:Comprehensive Routing System for Content Platforms
// Covering Parallel Routes + Intercepting Routes + Conditions Dashboard
// ============================================
// src/app/layout.tsx — Root Layout (with modal slot)
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-gray-50">
<header className="bg-white shadow-sm sticky top-0 z-40">
<div className="max-w-6xl mx-auto px-4 py-3 flex justify-between">
<a href="/" className="text-xl font-bold text-blue-600">PhotoVault</a>
<nav className="flex gap-4">
<a href="/" className="hover:text-blue-600">Feed</a>
<a href="/dashboard" className="hover:text-blue-600">Dashboard</a>
</nav>
</div>
</header>
{children}
{modal}
</body>
</html>
);
}
// src/app/@modal/default.tsx
export default function Default() {
return null;
}
// src/app/@modal/(.)photos/[id]/page.tsx — Block Pop-ups
'use client';
import { useRouter } from "next/navigation";
export default function PhotoModal({ params }) {
const router = useRouter();
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onClick={() => router.back()}
>
<div
className="bg-white rounded-xl max-w-2xl w-full mx-4 overflow-hidden shadow-2xl"
onClick={e => e.stopPropagation()}
>
<img
src={`https://picsum.photos/seed/${params.id}/800/600`}
alt=""
className="w-full"
/>
<div className="p-4 flex justify-between items-center">
<div>
<h2 className="font-bold">Photo #{params.id}</h2>
<p className="text-sm text-gray-500">Click background to close</p>
</div>
<button
onClick={() => router.push(`/photos/${params.id}`)}
className="text-sm text-blue-600 hover:underline"
>
Open Full Page
</button>
</div>
</div>
</div>
);
}
// src/app/(feed)/page.tsx — Feed stream
import Link from "next/link";
export default function FeedPage() {
const photos = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
url: `https://picsum.photos/seed/${i + 1}/400/400`,
title: `Photo ${i + 1}`,
}));
return (
<div className="max-w-6xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Photo Feed</h1>
<div className="grid grid-cols-4 gap-4">
{photos.map(photo => (
<Link
key={photo.id}
href={`/photos/${photo.id}`}
className="block aspect-square rounded-lg overflow-hidden bg-gray-100"
>
<img
src={photo.url}
alt={photo.title}
className="w-full h-full object-cover hover:scale-105 transition-transform"
/>
</Link>
))}
</div>
</div>
);
}
// src/app/photos/[id]/page.tsx — Full Image Page
export default async function PhotoPage({ params }) {
const { id } = params;
return (
<div className="max-w-4xl mx-auto p-8">
<a href="/" className="text-blue-600 hover:underline mb-4 inline-block">
← Back to Feed
</a>
<img
src={`https://picsum.photos/seed/${id}/1200/800`}
alt=""
className="w-full rounded-lg shadow-lg"
/>
<div className="mt-6">
<h1 className="text-3xl font-bold">Photo #{id}</h1>
<p className="text-gray-500 mt-2">
This is the full-page view. Share this link directly with others.
</p>
</div>
</div>
);
}
// src/app/(dashboard)/layout.tsx — Dashboard Parallel Routing Layout
export default function DashboardLayout({
children,
admin,
}: {
children: React.ReactNode;
admin: React.ReactNode;
}) {
const role = "admin";
return (
<div className="max-w-6xl mx-auto p-8 flex gap-8">
<div className="flex-1">{children}</div>
<aside className="w-80">
{role === "admin" && admin}
</aside>
</div>
);
}
// src/app/(dashboard)/@admin/default.tsx
export default function AdminPanel() {
return (
<div className="bg-white rounded-xl shadow-sm border p-6 space-y-4">
<h3 className="font-bold text-lg">Admin Panel</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span>Total Photos</span>
<span className="font-bold">1,234</span>
</div>
<div className="flex justify-between">
<span>Daily Uploads</span>
<span className="font-bold text-green-600">+48</span>
</div>
<div className="flex justify-between">
<span>Storage Used</span>
<span className="font-bold">237 GB</span>
</div>
</div>
<button className="w-full p-2 bg-blue-600 text-white rounded-lg">
Manage Gallery
</button>
</div>
);
}
// src/app/(dashboard)/page.tsx — Dashboard Home
export default function DashboardPage() {
return (
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-500 mt-2">Overview of your photo gallery</p>
<div className="grid grid-cols-2 gap-4 mt-6">
<div className="bg-white p-6 rounded-xl shadow-sm border">
<p className="text-sm text-gray-500">This Week</p>
<p className="text-3xl font-bold mt-1">342</p>
<p className="text-sm text-green-600 mt-1">↑ 12% from last week</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border">
<p className="text-sm text-gray-500">Total Views</p>
<p className="text-3xl font-bold mt-1">89.4K</p>
<p className="text-sm text-green-600 mt-1">↑ 8% from last month</p>
</div>
</div>
</div>
);
}
Expected Output:
1. Visit / → 12 Image Grid
2. Click on the image → Display a larger image in a pop-up window(URL: /photos/5)
3. Click on the background → Close Pop-up,Back to Feed
4. Refresh → Show Full Text /photos/5 Page
5. Share /photos/5 → Friends saw the entire page
6. Visit /dashboard → Show Dashboard + Admin Panel
7. From the Dashboard, click on the image → Same popup experience (Intercept Routes)
❓ FAQ
export default function Default() { return null; }.(..) for Intercepting Routes calculated?(..) is calculated based on the actual file system hierarchy, but Route Groups (group) do not consume levels. For example, app/(feed)/photos/(.)[id]/page.tsx intercepts app/(feed)/photos/[id]/page.tsx because (feed) is not counted.if (role === 'admin')) is also possible, Parallel Routes keeps each character’s view file independent, type-safe, and easier to test and separate.📖 Summary
- Parallel Routes: Implementing Multiple Independent Rendering Areas for a Single URL Using the
@slotNaming Convention - Each
@slotmust containdefault.tsx; display when no matching route is found - Intercepting Routes: Use
(.),(..),(..)(..), and(...)to match routes at different levels - The "Modal + Intercepting Route" combination implements "URL-driven pop-ups," balancing user experience and SEO
- Route interception only takes effect when the client navigates; direct access or refreshing the page displays the entire page.
(...)matches root-level routes,(.)matches routes at the same level,(..)matches routes at the parent level- Conditional rendering is implemented using Parallel Routes slots and role-based checks, with each role's view in a separate file.
- Router Groups
(group)Do not consume interception level calculations
📝 Exercises
-
Basic Exercise (⭐): Create a
@modalslot in your project and implement the basic functionality of "click the button → display a text snippet in a popup" (usedefault.tsxto returnnull). -
Advanced Exercise (⭐⭐): Create an image browsing system: The Feed page (
/) displays a grid of images; clicking an image opens a pop-up window ((.)photos/[id]); and directly accessing/photos/1displays the full page. Verify the behavior of the back button and the refresh function. -
Challenge (⭐⭐⭐): Implement a role-driven dashboard system: Create three slots—
@admin,@editor, and@viewer—so that each role displays different dashboard content (administrators see system monitoring, editors see draft statistics, and viewers see read-only prompts). Verify the conditional rendering by switching therolevariable.



