概要
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 はルート遷移を割り込んで別コンポーネントで表示する