Next.js: 並列ルートとインターセプトルート

最終更新:2026-08-26

並列ルーティングはマルチモニターのようなものです。各画面は独立して動作しますが相互接続されています。インターセプトルーティングは荷物の受け取り所のようなものです。途中で荷物をインターセプトして処理し、その後配送を続けます。

1. 学ぶこと



2. プロダクトマネージャーの実話

(1) ペインポイント: フィード内のポップアップ体験が悪い

Alice はコンテンツプラットフォームの画像閲覧機能を開発中で、ユーザー体験の問題に直面しています:

「ユーザーがフィード内の画像をタップすると、フルスクリーンのモーダルが表示され、URL が /photos/123 に変わりました。その結果、ユーザーがブラウザの戻るボタンでフィードに戻ろうとすると、アプリ全体からログアウトされてしまいました。ユーザーは『写真を見たかっただけなのに、なぜホームに戻れないの?』と不満を言いました。」

Alice が分析した問題:

問題 影響 ユーザー評価
モーダルを個別にリフレッシュできない モーダルのリフレッシュでページ全体がリフレッシュされる 2/5
戻るボタンでアプリが終了する ユーザーフローの中断 1.5/5
サイドバーとメインコンテンツが同期しない ナビゲーションが混乱する 2.5/5
チームビューとダッシュボードが分離している 管理効率が低い 3/5

(2) 並列 + インターセプトルートの解決策

@modal を使用してポップアップウィンドウを表示する並列ルーティング + (.)photo でフィードストリーム内の画像ナビゲーションをインターセプトします。

TEXT 📖 参照専用
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) 概念と使い方

100%
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/ なし チームビュー

▶ サンプル: 基本的な並列ルート

💻 出力:

TEXT 📖 参照専用
Diagram of route structure: file system paths map to URL paths.
TSX
// ============================================
// 並列ルートの基本: ダッシュボードのマルチスロットレイアウト
// ============================================

// 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>
  );
}
💻 出力:

TEXT 📖 参照専用
Renders: Dashboard layout with dark sidebar (Dashboard/Projects/Settings links) + main content area.
TSX
// 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>
  );
}
TSX
// 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>
  );
}
💻 出力:

TEXT 📖 参照専用
/dashboard にアクセス:
┌──────────────────────┬──────────┬──────────┐
│                      │          │          │
│   メインコンテンツエリア      │ サイドバー  │  チーム    │
│   Dashboard          │ Recent   │  Alice   │
│   Welcome back!      │ Activity │  Bob     │
│                      │ Notif(3) │  Charlie │
│                      │          │  Diana   │
└──────────────────────┴──────────┴──────────┘

3つの領域が独立してレンダリングされ、互いに影響しない

(2) default.tsx — 必須のデフォルト状態

@slot ディレクトリには 必ず default.tsx が必要で、一致するルートが見つからない場合に表示されます。

100%
graph TB
    A[ユーザーがナビゲート] --> B{現在の URL が<br/>スロットルートに一致する?}
    B -->|一致する| C[スロットの page.tsx を表示]
    B -->|一致しない| D[スロットの default.tsx を表示]

    style C fill:#d4edda
    style D fill:#f8d7da

▶ サンプル: default.tsx の重要性

💻 出力:

TEXT 📖 参照専用
Diagram: User Navigation; Show slot's page.tsx; Show slot's default.tsx.
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>
  );
}
💻 出力:

TEXT 📖 参照専用
Includes a sidebar.
💻 出力:

TEXT 📖 参照専用
/dashboard にアクセス:
- @modal に一致するルートがない → ModalDefault を表示 (= null、レンダリングしない)
- @sidebar に一致するルートがない → SidebarDefault を表示 (クイックリンクナビゲーション)
- @team に一致するルートがない → TeamDefault を表示 (チームメンバー一覧)

/dashboard/photos/1 にアクセス (仮定: @modal に一致が見つかる):
- @modal が一致 → ポップアップウィンドウのコンテンツを表示
- 他のスロットはそれぞれの default.tsx を表示


4. インターセプトルート

(1) インターセプトマッチングルール

100%
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] をインターセプト

▶ サンプル: 画像閲覧のブロック

💻 出力:

TEXT 📖 参照専用
Diagram: Currently in Feed stream; Intercept (.)photo; Display a pop-up in the Feed; The user refreshes the page; Bypass Block Show Entire Page.
TSX
// ============================================
// インターセプトルート + 並列ルート
// フィード内で画像をクリック → 現在のページにポップアップウィンドウを表示
// ============================================

// ディレクトリ構造:
// 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>
  );
}
💻 出力:

TEXT 📖 参照専用
Renders a dynamic list of items using .map().
Visible content: Photo Feed
TSX
// ============================================
// インターセプトルート: フィード内でモーダルポップアップを開く
// ファイル: 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>
  );
}
TSX
// ============================================
// フルページ: 独立してアクセスされた場合に全ページを表示
// ファイル: 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"
      >
        &larr; 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>
  );
}
💻 出力:

TEXT 📖 参照専用
シーン1: フィードから画像をクリック
- 現在 / ページ (Photo Feed グリッド)
- 1枚目の画像をクリック
- ポップアップウィンドウに大きい画像を表示 (アドレスバーが /photos/1 に変わる)
- 背景マスクをクリック → router.back() → フィードストリームに戻る
- ブラウザの戻るボタンをクリック → ポップアップを閉じる → フィードストリームは変わらない

シーン2: /photos/1 に直接アクセス
- インターセプトルートをスキップ
- フルページを表示 (全幅の大きな画像 + 前へ/次へボタン)
- 直接リフレッシュ可能、リンク共有可能

シーン3: ポップアップページをリフレッシュ
- フィード内のポップアップ → F5 でリフレッシュ
- インターセプトルールが発動しない (フィードナビゲーションからでない)
- フルテキスト /photos/1 ページを表示

(2) 多段インターセプト

▶ サンプル: 多段インターセプトルーティング

💻 出力:

TEXT 📖 参照専用
The page renders as described above, with the UI updating based on the described behavior.
TSX
// ============================================
// 多段インターセプトルーティングの例
// ディレクトリ構造:
// 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>
  );
}
💻 出力:

TEXT 📖 参照専用
CategoryPhotoModal renders its UI.
💻 出力:

TEXT 📖 参照専用
/categories/nature から画像をクリック:
1. ポップアップウィンドウに大きい画像を表示 (アドレスバー /photos/1)
2. Close または背景をクリック → router.back() → /categories/nature に戻る
3. 直接 /photos/1 にアクセス → フルページを表示 (ポップアップなし)
4. カテゴリページのポップアップが正常に動作


5. モーダル + 並列ルートの組み合わせモデル

(1) アーキテクチャ設計

100%
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 を共有 共有可能 ポップアップなし 受信者はフルページを見る

▶ サンプル: 完全なモーダル

💻 出力:

TEXT 📖 参照専用
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.
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/@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>
  );
}
💻 出力:

TEXT 📖 参照専用
Renders: Navigation component using useRouter for programmatic routing.
💻 出力:

TEXT 📖 参照専用
ユーザーフロー:

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) ロールに基づいて異なるビューをレンダリング

100%
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

▶ サンプル: ロール駆動ダッシュボード

💻 出力:

TEXT 📖 参照専用
Diagram of role-based access: admin (full), editor (read/write), viewer (read-only).
TSX
// ============================================
// 条件付きレンダリング: ロールに応じて異なるダッシュボードを表示
// ============================================

// 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>
  );
}
💻 出力:

TEXT 📖 参照専用
Renders the ▶ Example: Role-Driven Dashboard component UI as described in the section.
TSX
// 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>
  );
}
💻 出力:

TEXT 📖 参照専用
管理者 (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. 完全な例: フィード + モーダル + 条件付きダッシュボード

TSX
// ============================================
// 総合例: コンテンツプラットフォームの包括的なルーティングシステム
// 並列ルート + インターセプトルート + 条件付きダッシュボードをカバー
// ============================================

// 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">
        &larr; 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>
  );
}

期待される出力:

TEXT 📖 参照専用
1. / にアクセス → 12 枚の画像グリッド
2. 画像をクリック → ポップアップウィンドウに大きい画像を表示 (URL: /photos/5)
3. 背景をクリック → ポップアップを閉じる、フィードに戻る
4. リフレッシュ → フルテキスト /photos/5 ページを表示
5. /photos/5 を共有 → 友達は全ページを見る
6. /dashboard にアクセス → Dashboard + Admin Panel を表示
7. Dashboard から画像をクリック → 同じポップアップ体験 (インターセプトルート)

❓ よくある質問

Q すべての @slot に default.tsx ファイルが必要ですか?
A はい。default.tsx ファイルがない場合、現在の URL がそのスロットのルートに一致しないときに Next.js は 404 エラーを返します。モーダルスロットがほとんどの時間表示されなくても、export default function Default() { return null; } が必要です。
Q 並列ルートのスロット間で通信できますか?
A 直接通信することはできません。各スロットは独立したサーバーコンポーネントであり、状態を共有できません。通信が必要な場合は、URL パラメータ (searchParams) で値を渡すか、ルートレイアウトで Context Provider を使用します。
Q インターセプトルートの (..) のレベルはどのように計算されますか?
A (..) は実際のファイルシステム階層に基づいて計算されますが、ルートグループ (group) はレベルを消費しません。例えば、app/(feed)/photos/(.)[id]/page.tsxapp/(feed)/photos/[id]/page.tsx をインターセプトします。(feed) はカウントされないためです。
Q モーダル + インターセプトルートモードで URL に直接アクセスするとどうなりますか?
A 直接アクセス (またはリフレッシュ) した場合、ルートインターセプトは発動せず、ユーザーは完全なスタンドアロンページを見ることになります。これがこのアプローチの優れた点で、ルートインターセプトによって「段階的な劣化」を実現し、URL を直接アクセス・共有できるようにしています。
Q 並列ルートはページパフォーマンスに影響しますか?
A わずかに影響します。各スロットは独立したサーバーコンポーネントであり、個別にレンダリングする必要があるためです。ただし、Next.js はこれらのリクエストを自動的に並列化し、直列レンダリングより高速になります。推奨: 3〜4 スロットを超えず、不必要なスロットを避けてください。
Q 条件付きレンダリングは並列ルートを使うべきか、クライアントサイドで処理すべきか?
A 並列ルートを推奨します。クライアントサイドの条件付きレンダリング (if (role === 'admin')) も可能ですが、並列ルートは各ロールのビューファイルを独立させ、型安全でテストと分離が容易です。

📖 まとめ


📝 練習問題

  1. 基礎問題 (⭐): プロジェクトに @modal スロットを作成し、「ボタンをクリック → テキストスニペットをポップアップ表示」の基本機能を実装してください (default.tsxnull を返す)。

  2. 発展問題 (⭐⭐): 画像閲覧システムを作成してください: フィードページ (/) に画像グリッドを表示し、画像をクリックするとポップアップウィンドウを開き ((.)photos/[id])、/photos/1 に直接アクセスするとフルページを表示します。戻るボタンとリフレッシュ機能の動作を検証してください。

  3. チャレンジ (⭐⭐⭐): ロール駆動のダッシュボードシステムを実装してください: @admin@editor@viewer の3つのスロットを作成し、各ロールで異なるダッシュボードコンテンツを表示します (管理者はシステムモニタリング、編集者は下書き統計、閲覧者は読み取り専用プロンプト)。role 変数を切り替えて条件付きレンダリングを検証してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%