レビュー済み·難易度: 中級·更新: 2026-04-18

Next.js Intercepting Routes で URL 付きモーダルを実装する

Intercepting Routes((..) 記法)と Parallel Routes を組み合わせ、リスト画面の URL を維持したままモーダルで詳細を表示する実装例。

nextjsroutingui-component

対応バージョン

nextjs 15react 19

前提環境

Next.js App Router の動的ルートと Parallel Routes(@スロット)の基本を理解していること

概要

Intercepting Routes((..) 記法)と @modal スロットを組み合わせ、URL に紐付いたモーダルを実装する。リスト画面から遷移するとモーダル表示、直接 URL にアクセスするとフルページ表示になる。nextjs-parallel-routes との違いはレイアウト並列ではなく「ルートの割り込み」。

インストール

# 追加インストールは不要

実装

ディレクトリ構成

app/
  layout.tsx                 ← @modal スロットを受け取るルートレイアウト
  page.tsx                   ← 写真一覧ページ
  @modal/                    ← Parallel Routes スロット
    (..)photos/[id]/
      page.tsx               ← モーダルとして表示する詳細(Intercepting Route)
    default.tsx              ← スロットのデフォルト(モーダルなし状態)
  photos/
    [id]/
      page.tsx               ← 直接アクセス時のフルページ詳細

ルートレイアウト

// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}

一覧ページ

// app/page.tsx
import Link from "next/link";

const photos = Array.from({ length: 9 }, (_, i) => ({
  id: i + 1,
  title: `写真 ${i + 1}`,
  color: ["#e0f2fe", "#fce7f3", "#dcfce7", "#fef9c3"][i % 4],
}));

export default function GalleryPage() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 text-2xl font-bold">ギャラリー</h1>
      <div className="grid grid-cols-3 gap-3">
        {photos.map((photo) => (
          <Link key={photo.id} href={`/photos/${photo.id}`}>
            <div
              className="flex h-32 items-center justify-center rounded-lg text-sm font-medium text-gray-600 hover:opacity-80"
              style={{ backgroundColor: photo.color }}
            >
              {photo.title}
            </div>
          </Link>
        ))}
      </div>
    </main>
  );
}

Intercepting Route(モーダル表示)

// app/@modal/(..)photos/[id]/page.tsx
import { PhotoModal } from "@/components/PhotoModal";

type Props = { params: Promise<{ id: string }> };

export default async function InterceptedPhotoPage({ params }: Props) {
  const { id } = await params;
  return <PhotoModal id={Number(id)} />;
}

モーダルコンポーネント

// components/PhotoModal.tsx
"use client";

import { useRouter } from "next/navigation";

export function PhotoModal({ id }: { id: number }) {
  const router = useRouter();

  return (
    // 背景クリックで戻る
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
      onClick={() => router.back()}
    >
      <div
        className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"
        onClick={(e) => e.stopPropagation()}
      >
        <h2 className="mb-4 text-xl font-bold">写真 {id}</h2>
        <div className="mb-4 flex h-48 items-center justify-center rounded-lg bg-gray-100 text-gray-400">
          画像プレースホルダー
        </div>
        <button
          onClick={() => router.back()}
          className="rounded bg-gray-100 px-4 py-2 text-sm hover:bg-gray-200"
        >
          閉じる
        </button>
      </div>
    </div>
  );
}

モーダルスロットのデフォルト

// app/@modal/default.tsx
export default function ModalDefault() {
  return null;
}

フルページ詳細(直接アクセス時)

// app/photos/[id]/page.tsx
import Link from "next/link";

type Props = { params: Promise<{ id: string }> };

export default async function PhotoPage({ params }: Props) {
  const { id } = await params;
  return (
    <main className="mx-auto max-w-md p-8">
      <Link href="/" className="mb-6 block text-sm text-blue-600 hover:underline">
        ← 一覧に戻る
      </Link>
      <h1 className="mb-4 text-2xl font-bold">写真 {id}</h1>
      <div className="flex h-64 items-center justify-center rounded-lg bg-gray-100 text-gray-400">
        画像プレースホルダー
      </div>
    </main>
  );
}

ポイント

  • @modal/(..)photos/[id]/page.tsx(..) が Intercepting Routes の記法。一段上(..)のルートセグメント photos/[id] を「割り込んで」レンダリングする
  • リスト画面から <Link href="/photos/1"> で遷移するとモーダルが表示され、/photos/1 に直接アクセスするとフルページが表示される(同一 URL で挙動が変わる)
  • router.back() でブラウザ履歴を戻ることでモーダルを閉じる。URL は一覧ページに戻る
  • @modal/default.tsx でモーダルが表示されていない初期状態(null)を明示する。これがないとエラーになる
  • nextjs-parallel-routes との違い: Parallel Routes は同一ページに複数ルートを並列表示、Intercepting Routes はルート遷移を割り込んで別コンポーネントで表示する

注意点

nextjs-parallel-routes は独立コンテンツの並列表示。これは URL に紐付いたモーダルで、直接 URL にアクセスするとフルページ表示、リスト画面からはモーダル表示になる。

関連サンプル