Next.js: 並列ルートとインターセプトルート
最終更新:2026-08-26
並列ルーティングはマルチモニターのようなものです。各画面は独立して動作しますが相互接続されています。インターセプトルーティングは荷物の受け取り所のようなものです。途中で荷物をインターセプトして処理し、その後配送を続けます。
1. 学ぶこと
- 並列ルート: 単一 URL から複数の独立したビューをレンダリングする
@modal、@sidebar、@team命名スロットの使用方法- インターセプトルート:
(.)、(..)、(...)のマッチングルール - モーダル + 並列ルーティングの組み合わせ: URL 駆動モーダル
- 条件付きレンダリング: ユーザーロールに基づいて異なるダッシュボードビューをレンダリングする
2. プロダクトマネージャーの実話
(1) ペインポイント: フィード内のポップアップ体験が悪い
Alice はコンテンツプラットフォームの画像閲覧機能を開発中で、ユーザー体験の問題に直面しています:
「ユーザーがフィード内の画像をタップすると、フルスクリーンのモーダルが表示され、URL が
/photos/123に変わりました。その結果、ユーザーがブラウザの戻るボタンでフィードに戻ろうとすると、アプリ全体からログアウトされてしまいました。ユーザーは『写真を見たかっただけなのに、なぜホームに戻れないの?』と不満を言いました。」
Alice が分析した問題:
| 問題 | 影響 | ユーザー評価 |
|---|---|---|
| モーダルを個別にリフレッシュできない | モーダルのリフレッシュでページ全体がリフレッシュされる | 2/5 |
| 戻るボタンでアプリが終了する | ユーザーフローの中断 | 1.5/5 |
| サイドバーとメインコンテンツが同期しない | ナビゲーションが混乱する | 2.5/5 |
| チームビューとダッシュボードが分離している | 管理効率が低い | 3/5 |
(2) 並列 + インターセプトルートの解決策
@modalを使用してポップアップウィンドウを表示する並列ルーティング +(.)photoでフィードストリーム内の画像ナビゲーションをインターセプトします。
src/app/
├── layout.tsx # メインレイアウト: @children + @modal
└── (feed)/
├── layout.tsx # フィードレイアウト: @children + @sidebar
├── page.tsx # フィードストリームホームページ
└── photos/
├── [id]/
│ └── page.tsx # フルページ: /photos/123
└── (.)[id]/
└── page.tsx # インターセプト: フィード内でモーダルとして開く
(3) 効果
| 次元 | 導入前 (標準ルーティング) | 導入後 (並列 + インターセプト) |
|---|---|---|
| 画像閲覧体験 | フルページリダイレクト、閲覧が中断される | ポップアッププレビュー、現在のページに留まる |
| 戻るボタンの動作 | アプリを終了 | モーダルを閉じてフィードに戻る |
| ページのリフレッシュ | 非対応 | リフレッシュで全ページ表示、正常にレンダリング |
| サイドバーの独立性 | 独立していない | @sidebar が独立してレンダリング、メインコンテンツに影響なし |
3. 並列ルート
(1) 概念と使い方
graph TB
subgraph "URL: /dashboard"
A[layout.tsx] --> B[children<br/>メインコンテンツ]
A --> C[@modal<br/>ポップアップスロット]
A --> D[@sidebar<br/>サイドバースロット]
A --> E[@team<br/>チームスロット]
end
subgraph "レンダリング結果"
F[メインコンテンツエリア] & G[ポップアップエリア] & H[サイドバーエリア] & I[チームエリア]
end
style A fill:#cce5ff
style B fill:#d4edda
| スロット名 | ディレクトリプレフィックス | URL への影響 | ユースケース |
|---|---|---|---|
@children |
なし (デフォルト) | 標準 URL | メインページコンテンツ |
@modal |
@modal/ |
なし | ポップアップ、ダイアログボックス |
@sidebar |
@sidebar/ |
なし | サイドバーパネル |
@team |
@team/ |
なし | チームビュー |
▶ サンプル: 基本的な並列ルート
Diagram of route structure: file system paths map to URL paths.
// ============================================
// 並列ルートの基本: ダッシュボードのマルチスロットレイアウト
// ============================================
// src/app/(dashboard)/layout.tsx — 並列ルーティングレイアウト
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 className="flex-1 p-8 overflow-auto">
{children}
</main>
{/* サイドバースロット (独立した並列レンダリング) */}
<aside className="w-72 bg-gray-50 p-4 border-l">
{sidebar}
</aside>
{/* チームスロット (独立した並列レンダリング) */}
<aside className="w-64 bg-gray-900 text-white p-4">
{team}
</aside>
{/* モーダルスロット (条件付きレンダリング) */}
{modal}
</div>
);
}
Renders: Dashboard layout with dark sidebar (Dashboard/Projects/Settings links) + main content area.
// src/app/(dashboard)/@sidebar/default.tsx — サイドバーのデフォルト状態
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 — チームスロットのデフォルト状態
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>
);
}
/dashboard にアクセス:
┌──────────────────────┬──────────┬──────────┐
│ │ │ │
│ メインコンテンツエリア │ サイドバー │ チーム │
│ Dashboard │ Recent │ Alice │
│ Welcome back! │ Activity │ Bob │
│ │ Notif(3) │ Charlie │
│ │ │ Diana │
└──────────────────────┴──────────┴──────────┘
3つの領域が独立してレンダリングされ、互いに影響しない
(2) default.tsx — 必須のデフォルト状態
各 @slot ディレクトリには 必ず default.tsx が必要で、一致するルートが見つからない場合に表示されます。
graph TB
A[ユーザーがナビゲート] --> B{現在の URL が<br/>スロットルートに一致する?}
B -->|一致する| C[スロットの page.tsx を表示]
B -->|一致しない| D[スロットの default.tsx を表示]
style C fill:#d4edda
style D fill:#f8d7da
▶ サンプル: default.tsx の重要性
Diagram: User Navigation; Show slot's page.tsx; Show slot's default.tsx.
// ============================================
// default.tsx がないと 404 が発生する
// 各 @slot には必ず default.tsx が必要
// ============================================
// src/app/@modal/default.tsx — モーダルはデフォルトで非表示
export default function ModalDefault() {
return null; // モーダルがない場合は何もレンダリングしない
}
// 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>
);
}
Includes a sidebar.
/dashboard にアクセス:
- @modal に一致するルートがない → ModalDefault を表示 (= null、レンダリングしない)
- @sidebar に一致するルートがない → SidebarDefault を表示 (クイックリンクナビゲーション)
- @team に一致するルートがない → TeamDefault を表示 (チームメンバー一覧)
/dashboard/photos/1 にアクセス (仮定: @modal に一致が見つかる):
- @modal が一致 → ポップアップウィンドウのコンテンツを表示
- 他のスロットはそれぞれの default.tsx を表示
4. インターセプトルート
(1) インターセプトマッチングルール
graph TB
A[現在フィードストリーム内] --> B{画像リンクをクリック}
B --> C[インターセプト (.)photo]
C --> D[フィード内にポップアップを表示]
D --> E[ユーザーがページをリフレッシュ]
E --> F[ブロックをバイパス<br/>全ページを表示]
style C fill:#cce5ff
style D fill:#d4edda
style F fill:#f8d7da
| 構文 | マッチングレベル | 例 |
|---|---|---|
(.) |
同一レベル | feed/photos/(.)[id] が feed/photos/[id] をインターセプト |
(..) |
1つ上 | feed/(..)photos/[id] が photos/[id] をインターセプト |
(..)(..) |
2つ上 | feed/(..)(..)photos/[id] がルートレベルの photos/[id] をインターセプト |
(...) |
ルートレベル | feed/(...)photos/[id] が app/photos/[id] をインターセプト |
▶ サンプル: 画像閲覧のブロック
Diagram: Currently in Feed stream; Intercept (.)photo; Display a pop-up in the Feed; The user refreshes the page; Bypass Block Show Entire Page.
// ============================================
// インターセプトルート + 並列ルート
// フィード内で画像をクリック → 現在のページにポップアップウィンドウを表示
// ============================================
// ディレクトリ構造:
// app/
// layout.tsx # ルートレイアウト (@modal 付き)
// (feed)/
// page.tsx # フィードストリーム
// photos/
// [id]/page.tsx # フルページ: /photos/1
// @modal/
// default.tsx # モーダルなし
// (.)photos/
// [id]/page.tsx # インターセプト: フィード内にポップアップを表示
// 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/(feed)/page.tsx — フィードストリーミングページ
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>
);
}
Renders a dynamic list of items using .map().
Visible content: Photo Feed
// ============================================
// インターセプトルート: フィード内でモーダルポップアップを開く
// ファイル: 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 (
// 背景マスク
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onClick={() => router.back()}
>
{/* モーダルコンテンツ — 背景をクリックで閉じる */}
<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>
);
}
// ============================================
// フルページ: 独立してアクセスされた場合に全ページを表示
// ファイル: 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>
);
}
シーン1: フィードから画像をクリック
- 現在 / ページ (Photo Feed グリッド)
- 1枚目の画像をクリック
- ポップアップウィンドウに大きい画像を表示 (アドレスバーが /photos/1 に変わる)
- 背景マスクをクリック → router.back() → フィードストリームに戻る
- ブラウザの戻るボタンをクリック → ポップアップを閉じる → フィードストリームは変わらない
シーン2: /photos/1 に直接アクセス
- インターセプトルートをスキップ
- フルページを表示 (全幅の大きな画像 + 前へ/次へボタン)
- 直接リフレッシュ可能、リンク共有可能
シーン3: ポップアップページをリフレッシュ
- フィード内のポップアップ → F5 でリフレッシュ
- インターセプトルールが発動しない (フィードナビゲーションからでない)
- フルテキスト /photos/1 ページを表示
(2) 多段インターセプト
▶ サンプル: 多段インターセプトルーティング
The page renders as described above, with the UI updating based on the described behavior.
// ============================================
// 多段インターセプトルーティングの例
// ディレクトリ構造:
// app/
// photos/
// [id]/page.tsx → /photos/1 (フルページ)
// (feed)/
// page.tsx → / (フィードストリーム)
// categories/
// [cat]/page.tsx → /categories/nature (カテゴリページ)
// (..)(..)photos/
// [id]/page.tsx → /photos/1 をインターセプト → カテゴリページにポップアップ
// ============================================
// マッチングルールの説明:
// (feed)/categories/[cat] のレベル = app/(feed)/categories/[cat]
// 対象: app/photos/[id] のレベル = app/photos/[id]
// 2つ上に戻ってマッチさせる必要がある → (..)(..)
// 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>
);
}
CategoryPhotoModal renders its UI.
/categories/nature から画像をクリック:
1. ポップアップウィンドウに大きい画像を表示 (アドレスバー /photos/1)
2. Close または背景をクリック → router.back() → /categories/nature に戻る
3. 直接 /photos/1 にアクセス → フルページを表示 (ポップアップなし)
4. カテゴリページのポップアップが正常に動作
5. モーダル + 並列ルートの組み合わせモデル
(1) アーキテクチャ設計
graph TB
subgraph "URL 駆動モーダルポップアップ"
A[ユーザーアクション] --> B{/photos/1 にナビゲート}
B --> C[フィード内?]
C -->|はい| D[@modal スロット<br/>一致してインターセプトルート]
C -->|いいえ| E[直接アクセス<br/>フルページを表示]
D --> F[ポップアップ表示<br/>アドレスバー更新]
F --> G[ユーザーが閉じる]
G --> H[router.back()]
H --> I[フィードに戻る<br/>モーダルが消える]
end
style D fill:#cce5ff
style E fill:#d4edda
style G fill:#f8d7da
| ユーザー操作 | URL 変更 | モーダル状態 | ページ状態 |
|---|---|---|---|
| フィード画像をクリック | / → /photos/1 |
ポップアップ表示 | フィードをバックグラウンドで保持 |
| ポップアップを閉じる | /photos/1 → / |
非表示 | フィード変更なし |
/photos/1 をリフレッシュ |
/photos/1 変更なし |
ポップアップなし | フルページを表示 |
/photos/1 を共有 |
共有可能 | ポップアップなし | 受信者はフルページを見る |
▶ サンプル: 完全なモーダル
Diagram: User Actions; In the Feed?; @modal slot Match and Intercept Routes; Direct Access Show Full Page; Pop-up Display Address Bar Update; User Logout.
// ============================================
// 完全なモーダル + 並列ルート + インターセプトルート
// 「リフレッシュ可能、共有可能、取り消し可能」なポップアップシステムを実装
// ============================================
// 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 — ポップアップのブロック
'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 — フィードストリーム
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>
);
}
Renders: Navigation component using useRouter for programmatic routing.
ユーザーフロー:
1. / にアクセス → 8枚の画像グリッドを表示
2. 画像 #3 をクリック → ポップアップウィンドウに大きい画像を表示 (URL: /photos/3)
3. 右上の ✕ をクリック → ポップアップを閉じる (URL: /)
4. 画像 #5 を再度クリック → ポップアップ (URL: /photos/5)
5. ブラウザの戻るボタンをクリック → ポップアップを閉じる (URL: /)
6. 戻るボタンを再度押す → アプリを終了 (正常な動作)
リフレッシュシーン:
1. ポップアップが開いている状態で F5 を押す → ページ全体をリフレッシュ
2. インターセプトルールが発動しない → /photos/3 のフルページを表示
3. ページは正常に機能し、戻るナビゲーションリンクがある
共有シナリオ:
1. /photos/3 の URL をコピーして友達に送る
2. 友達が開く → フルページを表示 (ポップアップではない)
3. ページ SEO 正常、全コンテンツがインデックス可能
6. ダッシュボードの条件付きレンダリング
(1) ロールに基づいて異なるビューをレンダリング
graph TB
A[ダッシュボードレイアウト] --> B{ユーザーロール}
B -->|admin| C[@admin パネル]
B -->|editor| D[@editor パネル]
B -->|viewer| E[@viewer パネル]
style A fill:#cce5ff
style C fill:#d4edda
style D fill:#d4edda
style E fill:#d4edda
▶ サンプル: ロール駆動ダッシュボード
Diagram of role-based access: admin (full), editor (read/write), viewer (read-only).
// ============================================
// 条件付きレンダリング: ロールに応じて異なるダッシュボードを表示
// ============================================
// src/app/(dashboard)/layout.tsx — ロールルーティングレイアウト
export default function DashboardLayout({
children,
admin,
editor,
viewer,
}: {
children: React.ReactNode;
admin: React.ReactNode;
editor: React.ReactNode;
viewer: React.ReactNode;
}) {
// Cookie/セッションからロールを取得することをシミュレート
const role = "admin";
return (
<div className="flex h-screen">
{/* メインコンテンツ */}
<main className="flex-1 p-8">
{children}
</main>
{/* ロールに応じて異なるスロットをレンダリング */}
<aside className="w-80 border-l p-4">
{role === "admin" && admin}
{role === "editor" && editor}
{role === "viewer" && viewer}
</aside>
</div>
);
}
Renders the ▶ Example: Role-Driven Dashboard component UI as described in the section.
// src/app/(dashboard)/@admin/default.tsx — 管理者パネル
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 — 編集者パネル
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 — 閲覧者パネル
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>
);
}
管理者 (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] │
└────────────────────────────┴──────────────────────┘
編集者 (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] │
└────────────────────────────┴──────────────────────┘
閲覧者 (role = "viewer"):
┌────────────────────────────┬──────────────────────┐
│ │ Overview │
│ Dashboard │ Read-only access │
│ Welcome, Charlie! │ Quick Links: │
│ │ • Documentation │
│ Team Activity │ • Reports │
│ 10 team members online │ • Help Center │
│ │ │
└────────────────────────────┴──────────────────────┘
7. 完全な例: フィード + モーダル + 条件付きダッシュボード
// ============================================
// 総合例: コンテンツプラットフォームの包括的なルーティングシステム
// 並列ルート + インターセプトルート + 条件付きダッシュボードをカバー
// ============================================
// src/app/layout.tsx — ルートレイアウト (モーダルスロット付き)
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 — ポップアップのブロック
'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 — フィードストリーム
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 — フル画像ページ
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 — ダッシュボード並列ルーティングレイアウト
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 — ダッシュボードホーム
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>
);
}
期待される出力:
1. / にアクセス → 12 枚の画像グリッド
2. 画像をクリック → ポップアップウィンドウに大きい画像を表示 (URL: /photos/5)
3. 背景をクリック → ポップアップを閉じる、フィードに戻る
4. リフレッシュ → フルテキスト /photos/5 ページを表示
5. /photos/5 を共有 → 友達は全ページを見る
6. /dashboard にアクセス → Dashboard + Admin Panel を表示
7. Dashboard から画像をクリック → 同じポップアップ体験 (インターセプトルート)
❓ よくある質問
export default function Default() { return null; } が必要です。(..) のレベルはどのように計算されますか?(..) は実際のファイルシステム階層に基づいて計算されますが、ルートグループ (group) はレベルを消費しません。例えば、app/(feed)/photos/(.)[id]/page.tsx は app/(feed)/photos/[id]/page.tsx をインターセプトします。(feed) はカウントされないためです。if (role === 'admin')) も可能ですが、並列ルートは各ロールのビューファイルを独立させ、型安全でテストと分離が容易です。📖 まとめ
- 並列ルート:
@slot命名規則を使用して、単一 URL に複数の独立したレンダリングエリアを実装 - 各
@slotにはdefault.tsxが必要で、一致するルートが見つからない場合に表示 - インターセプトルート:
(.)、(..)、(..)(..)、(...)を使用して異なるレベルのルートをマッチ - 「モーダル + インターセプトルート」の組み合わせは「URL 駆動ポップアップ」を実装し、ユーザー体験と SEO を両立
- ルートインターセプトはクライアントがナビゲートする場合にのみ発動し、直接アクセスまたはリフレッシュ時は全ページを表示
(...)はルートレベルのルートにマッチ、(.)は同レベルのルート、(..)は親レベルのルート- 条件付きレンダリングは並列ルートスロットとロールベースのチェックで実装し、各ロールのビューを個別ファイルに分離
- ルートグループ
(group)はインターセプトレベルの計算に消費されない
📝 練習問題
-
基礎問題 (⭐): プロジェクトに
@modalスロットを作成し、「ボタンをクリック → テキストスニペットをポップアップ表示」の基本機能を実装してください (default.tsxでnullを返す)。 -
発展問題 (⭐⭐): 画像閲覧システムを作成してください: フィードページ (
/) に画像グリッドを表示し、画像をクリックするとポップアップウィンドウを開き ((.)photos/[id])、/photos/1に直接アクセスするとフルページを表示します。戻るボタンとリフレッシュ機能の動作を検証してください。 -
チャレンジ (⭐⭐⭐): ロール駆動のダッシュボードシステムを実装してください:
@admin、@editor、@viewerの3つのスロットを作成し、各ロールで異なるダッシュボードコンテンツを表示します (管理者はシステムモニタリング、編集者は下書き統計、閲覧者は読み取り専用プロンプト)。role変数を切り替えて条件付きレンダリングを検証してください。