- Next.js
Next.js Parallel・Intercepting Routesでモーダル実装
Next.js の Parallel Routes(パラレルルート)とは、1つのレイアウトの中に複数のページを同時に表示する仕組みで、Intercepting Routes(インターセプトルート)とは、アプリ内のリンクで移動したときだけ、別の URL の内容を今の画面の上に表示する仕組みです。この2つを組み合わせると、「一覧で写真をクリックするとモーダルが開き、URL も /photos/2 に変わる。リロードや URL の共有では個別ページが開く」という、SNS のような画面を作れます。
この組み合わせの解説は多くありますが、Next.js 14 の頃の情報だったり、モーダルの閉じ方や default.tsx の扱いが省かれていたりするものも見かけます。この記事では Next.js 16.3.6 で写真一覧のモーダルを完全なコードで作り、Playwright(Chromium)でクリック・ESC キー・戻る・進む・リロードの挙動を1つずつ確かめた結果を表にしています。Next.js 16 で必須になった default.tsx の実際のビルドエラー、ダッシュボードの並列表示と、ログイン状態で出し分けるときの注意点も、動かして分かったことを含めて紹介します。
Parallel Routes・Intercepting Routes の早見表
| やりたいこと | ファイル・書き方 |
|---|---|
| レイアウトに「差し込み口」を作る | @modal のように @ 付きのフォルダー(スロット) |
| スロットを表示する | レイアウトの props に modal が届く → {children}{modal} |
| 同じ階層の URL を横取りする | (.)photos |
| 1つ上の階層の URL を横取りする | (..)photos |
| 2つ上の階層 | (..)(..)photos |
app 直下から数えた URL を横取りする |
(...)photos |
| モーダルを出さないとき | @modal/default.tsx で return null(Next.js 16 では必須) |
| リンクで別ページへ移ったら閉じる | @modal/[...catchAll]/page.tsx で return null |
| 閉じる(ボタン・ESC・背景) | router.back() |
| リロード・URL 直打ちのとき | 横取りされず、通常の app/photos/[id]/page.tsx が表示される |
| スロットごとの読み込み中表示 | @stats/loading.tsx |
| 役割で出し分ける | レイアウトで role === "admin" ? admin : member(各スロットでも権限を確認) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Next.js Parallel・Intercepting Routesでモーダル実装」
動作確認の環境
next 16.3.6、react 19.2.8、Node.js 22.22.2 で next build → next start し、Playwright 1.56.1(Chromium)で操作して確認しました。プロジェクトは npx create-next-app@16 で作成しています。
2つの機能の役割と、ソフト・ハードナビゲーション
Parallel Routes は「どこに表示するか」、Intercepting Routes は「いつ横取りするか」を受け持ちます。
| 機能 | 書き方 | 役割 | URL への影響 |
|---|---|---|---|
| Parallel Routes | @modal、@stats などのフォルダー |
レイアウトに、children とは別の表示場所(スロット)を作る |
なし(@modal は URL に含まれない) |
| Intercepting Routes | (.)photos などのフォルダー |
アプリ内の移動のときだけ、別の URL の内容をそのスロットに表示する | 移動先の URL(/photos/2)に変わる |
横取りが起きるかどうかは、移動の種類で決まります。
| 移動の種類 | 例 | 表示 |
|---|---|---|
| ソフトナビゲーション(アプリ内の移動) | <Link> のクリック、router.push、戻る・進む |
横取りされ、モーダルで表示 |
| ハードナビゲーション(ページ全体の読み込み) | リロード、URL の直打ち、共有されたリンク | 横取りされず、個別ページを表示 |
flowchart LR
A[/photos 一覧/] -- Link をクリック --> B[URL: /photos/2<br>一覧の上にモーダル]
B -- 戻る・ESC・背景クリック --> A
B -- リロード --> C[URL: /photos/2<br>個別ページ]
D[URLを直接開く] --> C
完成形のディレクトリ構成
app/
├── layout.tsx ← children と modal を並べて表示
├── page.tsx ← /photos へリダイレクト
├── about/page.tsx ← /about
├── photos/
│ ├── page.tsx ← /photos(一覧)
│ └── [id]/page.tsx ← /photos/2(個別ページ。リロード時はこれ)
└── @modal/ ← モーダル用のスロット(URL には出ない)
├── default.tsx ← 何もないときは null
├── [...catchAll]/page.tsx ← 別ページへ移ったら閉じる(null)
└── (.)photos/
└── [id]/page.tsx ← /photos/2 を横取りしてモーダル表示
components/
└── Modal.tsx ← 閉じる処理(Client Component)
lib/
└── photos.ts ← サンプルデータ
@modal の中の (.)photos は、@modal を数えずに「app と同じ階層の photos」を指します。スロットは URL の階層ではないためです。
インターセプトの記法一覧:(.)・(..)・(...)
(..) は、ファイルの階層ではなく「URL の階層(ルートセグメント)」で数えます。@スロット や (グループ) のフォルダーは数えません。
| 記法 | 意味 | 例(フォルダー → 横取りする URL) |
|---|---|---|
(.) |
同じ階層 | app/@modal/(.)photos/[id] → /photos/2 |
(..) |
1つ上の階層 | app/feed/(..)photo/[id] → /photo/2(/feed から見て1つ上) |
(..)(..) |
2つ上の階層 | app/a/b/(..)(..)photo/[id] → /photo/2 |
(...) |
app 直下から |
app/shop/items/(...)login → /login |
モーダルのスロットを一覧と同じフォルダーに置く書き方もあります。
| スロットの置き場所 | 横取りのフォルダー | モーダルが出る範囲 |
|---|---|---|
app/@modal(この記事) |
app/@modal/(.)photos/[id] |
どのページから /photos/2 へ移っても |
app/photos/@modal |
app/photos/@modal/(.)[id] |
/photos の下のページから移ったときだけ |
モーダルの完全なコード
サンプルデータとルートレイアウト
// lib/photos.ts:サンプルデータ(本番では DB や CMS から取得)
export type Photo = { id: string; title: string; color: string };
export const photos: Photo[] = [
{ id: "1", title: "朝の窓辺", color: "#f4a261" },
{ id: "2", title: "青いマグカップ", color: "#2a9d8f" },
{ id: "3", title: "机の上のノート", color: "#e9c46a" },
{ id: "4", title: "観葉植物", color: "#8ab17d" },
{ id: "5", title: "夕方の空", color: "#e76f51" },
{ id: "6", title: "本棚", color: "#264653" },
];
export function getPhoto(id: string) {
return photos.find((p) => p.id === id);
}
スロット名(@modal)が、そのままレイアウトの props 名(modal)になります。
// app/layout.tsx:@modal スロットを children と並べて描画する
import Link from "next/link";
import "./globals.css";
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<header style={{ display: "flex", gap: 16, padding: 16 }}>
<Link href="/photos">写真一覧</Link>
<Link href="/about">このサイトについて</Link>
</header>
{children}
{modal}
</body>
</html>
);
}
// app/page.tsx
import { redirect } from "next/navigation";
export default function Home() {
redirect("/photos");
}
// app/about/page.tsx
export default function About() {
return <main style={{ padding: 16 }}><h1>このサイトについて</h1></main>;
}
一覧ページと個別ページ
// app/photos/page.tsx:一覧。Link で遷移すると @modal 側で横取り(インターセプト)される
import Link from "next/link";
import { photos } from "@/lib/photos";
export default function PhotosPage() {
return (
<main style={{ padding: 16 }}>
<h1>写真一覧</h1>
<ul style={{ display: "grid", gridTemplateColumns: "repeat(3, 160px)", gap: 12, listStyle: "none", padding: 0 }}>
{photos.map((p) => (
<li key={p.id}>
<Link href={`/photos/${p.id}`} data-testid={`photo-${p.id}`}>
<div style={{ background: p.color, height: 120, borderRadius: 8 }} />
{p.title}
</Link>
</li>
))}
</ul>
</main>
);
}
// app/photos/[id]/page.tsx:個別ページ(URL直打ち・リロード・共有リンクで表示される)
import { notFound } from "next/navigation";
import { getPhoto, photos } from "@/lib/photos";
export function generateStaticParams() {
return photos.map((p) => ({ id: p.id }));
}
export default async function PhotoPage({ params }: PageProps<"/photos/[id]">) {
const { id } = await params;
const photo = getPhoto(id);
if (!photo) notFound();
return (
<main style={{ padding: 16 }}>
<h1 data-testid="full-title">{photo.title}(個別ページ)</h1>
<div style={{ background: photo.color, height: 360, borderRadius: 8 }} />
</main>
);
}
params は Promise なので await します。PageProps<"/photos/[id]"> は next dev・next build のときに自動で作られる型で、import は要りません。
@modal スロットの3つのファイル
// app/@modal/default.tsx:モーダルを出さないときは何も描画しない(Next.js 16 では必須)
export default function Default() {
return null;
}
// app/@modal/[...catchAll]/page.tsx:Link で別ページへ移ったときにモーダルを閉じる
export default function CatchAll() {
return null;
}
// app/@modal/(.)photos/[id]/page.tsx:/photos/[id] への「アプリ内の移動」を横取りしてモーダルで表示
import Link from "next/link";
import { notFound } from "next/navigation";
import { Modal } from "@/components/Modal";
import { getPhoto } from "@/lib/photos";
export default async function PhotoModal({ params }: PageProps<"/photos/[id]">) {
const { id } = await params;
const photo = getPhoto(id);
if (!photo) notFound();
return (
<Modal title={photo.title}>
<div style={{ background: photo.color, height: 240, borderRadius: 8 }} />
<Link href="/about" data-testid="modal-link">このサイトについて</Link>
</Modal>
);
}
モーダルの枠(Modal)と中身を分けておくと、中身は Server Component のままデータを取得できます。枠だけを Client Component にします。
閉じる処理:ボタン・ESC キー・背景クリック
// components/Modal.tsx:閉じる処理(ボタン・ESCキー・背景クリック)はすべて router.back()
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
export function Modal({ title, children }: { title: string; children: React.ReactNode }) {
const router = useRouter();
const closeButton = useRef<HTMLButtonElement>(null);
const close = useCallback(() => router.back(), [router]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden"; // 背景のスクロールを止める
closeButton.current?.focus(); // 開いたら閉じるボタンにフォーカス
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [close]);
return (
<div
data-testid="overlay"
onClick={(e) => {
if (e.target === e.currentTarget) close(); // 背景(外側)をクリックしたときだけ閉じる
}}
style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,.6)",
display: "grid", placeItems: "center",
}}
>
<div role="dialog" aria-modal="true" aria-label={title}
style={{ background: "#fff", color: "#111", padding: 24, borderRadius: 12, width: 420 }}>
<h2>{title}</h2>
{children}
<button ref={closeButton} onClick={close} style={{ marginTop: 12 }}>閉じる</button>
</div>
</div>
);
}
| 閉じ方 | 書き方 | ポイント |
|---|---|---|
| 閉じるボタン | onClick={close} |
開いたときにフォーカスを移すと、キーボードでも操作しやすい |
| ESC キー | keydown で e.key === "Escape" |
useEffect の後片付けでリスナーを外す |
| 背景クリック | e.target === e.currentTarget |
この判定がないと、モーダルの中をクリックしても閉じてしまう |
閉じる処理は router.push("/photos") ではなく router.back() にします。push だと履歴が1つ増えるため、閉じたあとにブラウザの「戻る」を押すとモーダルが再び開きます。back() なら、開く前の履歴にそのまま戻ります。
Playwright で確かめた挙動
next start で起動し、Chromium で一連の操作を行った結果です。「一覧」は写真一覧の見出し、「個別ページ」は app/photos/[id]/page.tsx の見出しが表示されているかを表します。
| 操作 | URL | モーダル | 一覧 | 個別ページ |
|---|---|---|---|---|
1. /photos を開く |
/photos |
なし | あり | なし |
| 2. 写真2をクリック | /photos/2 |
あり | あり | なし |
| 3. ESC キー | /photos |
なし | あり | なし |
| 4. 写真3 → 背景をクリック | /photos |
なし | あり | なし |
| 5. 写真4 → 閉じるボタン | /photos |
なし | あり | なし |
| 6. ブラウザの「進む」 | /photos/4 |
あり(再び開く) | あり | なし |
| 7. ブラウザの「戻る」 | /photos |
なし | あり | なし |
| 8. 写真5をクリック | /photos/5 |
あり | あり | なし |
| 9. そのままリロード | /photos/5 |
なし | なし | あり |
10. /photos/6 を直接開く |
/photos/6 |
なし | なし | あり |
11. モーダル内の Link で /about へ |
/about |
なし | なし | なし |
クリックでは URL が変わりつつ一覧の上にモーダルが開き、リロードすると個別ページに切り替わりました。閉じる処理を router.back() にしたことで、ESC・背景・ボタンのどれで閉じても URL が /photos に戻り、「進む」でモーダルが再び開きます。
catch-all がないと、別ページへ移ってもモーダルが残る
app/@modal/[...catchAll]/page.tsx を消してビルドし直し、11 と同じ操作をした結果です。
| 構成 | 11. モーダル内の Link で /about へ |
|---|---|
| catch-all あり | URL /about、モーダルなし |
| catch-all なし | URL /about、モーダルが残ったまま |
ソフトナビゲーションでは、新しい URL に一致しないスロットは「前の状態」を保つ仕様のためです。/about に一致する @modal のページがないと、直前のモーダルが表示され続けます。[...catchAll] で「どの URL でも null」を用意しておくと、別ページへ移ったときに閉じます。
default.tsx:Next.js 16 で必須に
default.tsx は、ハードナビゲーション(リロードなど)のときに、URL に一致しないスロットの代わりに表示されるファイルです。Next.js 16 のアップグレードガイドでは、「すべてのパラレルルートのスロットに default.js が必要になり、ないとビルドが失敗する」と説明されています。
後述のダッシュボード(/dashboard/settings という子ページがある構成)で、スロットの default.tsx を置かずにビルドした実際のエラーです。
> Build error occurred
Error: Turbopack build failed with 3 errors:
./app/dashboard/@admin
Error: Missing required default.js file for parallel route at /dashboard/@admin
The parallel route slot "@admin" is missing a default.js file. When using parallel routes, each slot must have a default.js file to serve as a fallback.
Create a default.js file at: /dashboard/@admin/default.js
一方、この検証では次のような違いもありました。
| 試した構成 | 結果 |
|---|---|
/dashboard/settings があり、@admin などに default.tsx がない |
ビルドエラー(上の文) |
子ページがない状態で、@admin などに default.tsx がない |
ビルドは成功 |
app/@modal から default.tsx と catch-all を消す |
ビルドは成功。/photos・/photos/1・/about がすべて 404 |
ビルドが通っても、@modal に default.tsx がないと、一致しない URL はすべて 404 になりました。エラーにならない場合もあるので、スロットを作ったら default.tsx を必ず一緒に作ると覚えておくと安心です。
default.tsx の中身 |
使いどころ |
|---|---|
return null |
モーダルなど、普段は何も出さないスロット |
export { default } from "./page" |
ダッシュボードの部品など、子ページでも同じ内容を出したいスロット |
notFound() |
一致しないときは 404 にしたい(Next.js 15 までの挙動) |
ダッシュボードを並列に表示する
モーダル以外の代表的な使い方が、ダッシュボードで複数の部品を並べて、それぞれ独立に読み込む形です。スロットごとに loading.tsx を置けるので、遅い部品があってもほかの部分を先に表示できます。
app/dashboard/
├── layout.tsx ← children・stats・admin・member を受け取る
├── page.tsx ← /dashboard
├── settings/page.tsx ← /dashboard/settings
├── @stats/
│ ├── page.tsx ← 集計(1.5秒かかる想定)
│ ├── loading.tsx ← 集計中の表示
│ └── default.tsx ← export { default } from "./page"
├── @admin/
│ ├── page.tsx
│ └── default.tsx
└── @member/
├── page.tsx
└── default.tsx
// app/dashboard/layout.tsx:3つのスロットを並べ、ログイン中の役割でどちらかを出し分ける
import { cookies } from "next/headers";
export default async function DashboardLayout({
children,
stats,
admin,
member,
}: {
children: React.ReactNode;
stats: React.ReactNode;
admin: React.ReactNode;
member: React.ReactNode;
}) {
const role = (await cookies()).get("role")?.value; // 実際はセッションから取得する
return (
<main style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, padding: 16 }}>
<section>{children}</section>
<section>{stats}</section>
<section style={{ gridColumn: "1 / -1" }}>{role === "admin" ? admin : member}</section>
</main>
);
}
// app/dashboard/@stats/page.tsx:時間のかかる集計(このスロットだけ後から表示される)
import { connection } from "next/server";
export default async function StatsSlot() {
await connection();
await new Promise((r) => setTimeout(r, 1500)); // 集計に1.5秒かかる想定
return <p data-testid="stats">今週の完了タスク:42件</p>;
}
// app/dashboard/@stats/loading.tsx
export default function Loading() {
return <p data-testid="stats-loading">集計中…</p>;
}
// app/dashboard/@stats/default.tsx(@admin・@member も同じ内容)
// /dashboard/settings を直接開いたときも、同じ内容を表示する
export { default } from "./page";
// app/dashboard/@member/page.tsx
export default function MemberSlot() {
return <p data-testid="member">あなたのタスク:3件</p>;
}
// app/dashboard/page.tsx
import Link from "next/link";
export default function DashboardPage() {
return (
<div>
<h1>ダッシュボード</h1>
<Link href="/dashboard/settings">設定へ</Link>
</div>
);
}
// app/dashboard/settings/page.tsx
export default function SettingsPage() {
return <h1>設定</h1>;
}
Cookie の role を変えて Playwright で開いた結果です(時間は、ページを開き始めてからの経過時間)。
| role | 見出しの表示 | 「集計中…」 | 集計の表示 | 表示されたスロット |
|---|---|---|---|---|
| member | 277ms | 表示された | 1758ms | @member のみ |
| admin | 184ms | 表示された | 1542ms | @admin のみ |
集計の1.5秒を待たずに見出しが表示され、集計のスロットだけが後から埋まりました。「設定へ」のリンクで移動したとき(ソフト)も、/dashboard/settings でリロードしたとき(ハード)も、集計のスロットは表示されたままでした。リロード時は default.tsx(page の再利用)が使われています。
ログイン状態で出し分けるときの注意
レイアウトで admin か member のどちらかを返しても、両方のスロットはサーバーで実行されます。公式ドキュメントにも、表示するかどうかを決めるだけで、実行を止めるわけではないと書かれています。実際に、権限を確認しない管理者用スロットで試しました。
// 確認のために、権限チェックを外した @admin/page.tsx
export default async function AdminSlot() {
return <p data-testid="admin">管理者メニュー:メンバー管理・請求設定</p>;
}
$ curl -s -b role=member http://localhost:7238/dashboard > member.html
$ grep -c 'data-testid="admin"' member.html
0
$ grep -o '管理者メニュー[^"<\\]*' member.html
管理者メニュー:メンバー管理・請求設定
画面の HTML には出ていませんが、ページに埋め込まれた React Server Components のデータの中に、管理者メニューの文字列が含まれていました。そのため、各スロットの中でも権限を確認し、対象外なら何も返さないようにします。
// app/dashboard/@admin/page.tsx:スロットの中でも権限を確認する
import { cookies } from "next/headers";
export default async function AdminSlot() {
const role = (await cookies()).get("role")?.value;
if (role !== "admin") return null;
return <p data-testid="admin">管理者メニュー:メンバー管理・請求設定</p>;
}
この形に戻して同じ curl を実行すると、管理者メニュー は1件も見つかりませんでした。画面の出し分けと、データを渡してよいかの確認は、分けて考えると安全です。
よくあるつまずきと対処
| 症状 | 原因 | 対処 |
|---|---|---|
| ページがすべて 404 | スロットに default.tsx がない |
default.tsx で return null |
Missing required default.js file でビルドが止まる |
Next.js 16 でスロットの default が必須 |
各スロットに default.tsx |
| リンクで別ページへ移ってもモーダルが残る | 移動先に一致するスロットのページがない | @modal/[...catchAll]/page.tsx で null |
| 閉じたあと「戻る」でモーダルが開く | router.push で閉じている |
router.back() にする |
| モーダルが開かず、ページ全体が切り替わる | (.) の階層がずれている/<a> で移動している |
URL の階層で数え直す。<Link> を使う |
| リロードするとモーダルが消える | 仕様(ハードナビゲーション) | 個別ページ(photos/[id]/page.tsx)を用意する |
| 中をクリックしても閉じる | 背景クリックの判定がない | e.target === e.currentTarget |
| 出していない管理者用の内容がデータに含まれる | 両方のスロットが実行される | 各スロットでも権限を確認 |
@modal を置いたのに props が undefined |
スロットとレイアウトの階層が違う | スロットは、それを受け取る layout.tsx と同じフォルダーに置く |
よくある質問
Q. @modal の @ と (.) の () はどう違いますか?
A. @modal は表示場所(スロット)を作るためのフォルダーで、URL には含まれません。(.)photos は「この URL への移動を横取りする」ためのフォルダーです。(marketing) のような丸かっこだけのフォルダーは「ルートグループ」という別の機能なので、(.) とは区別します。
Q. モーダルを開いたまま URL を共有すると、相手にはどう見えますか?
A. 共有された URL を開くのはハードナビゲーションなので、横取りは起きず、個別ページ(この記事では app/photos/[id]/page.tsx)が表示されます。検証でも、/photos/6 を直接開くと個別ページになりました。
Q. 状態(useState)で作るモーダルと、どちらを選べばよいですか?
A. URL で共有したい、リロードしても内容を保ちたい、「戻る」で閉じたい、という場合はこの記事の方法が向いています。確認ダイアログのように URL を持たせる必要がないものは、useState のモーダルのほうがシンプルです。
まとめ
@modalスロットで表示場所を作り、(.)photos/[id]で一覧からの移動を横取りすると、URL 付きのモーダルになる(..)は URL の階層で数え、@スロットは数えない- Playwright の確認で、クリックはモーダル、リロードと直打ちは個別ページになった
- 閉じる処理は
router.back()にすると、ESC・背景・ボタン・「戻る」「進む」の挙動がそろう [...catchAll]で null を返さないと、別ページへ移ってもモーダルが残る- Next.js 16 では各スロットに
default.tsxが必須。ない場合はビルドエラー、または一致しない URL が 404 - 出し分けたスロットも両方サーバーで実行されるので、各スロットで権限を確認する
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Next.js Parallel・Intercepting Routesでモーダル実装」
株式会社RJCでは、画面の動きの細かなところまで実際に試しながら、より使いやすい UI の作り方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。