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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?