【Next.js】Parallel RoutesとIntercept Routesを使ってモダンなモーダルを実装してみた
リテールアプリ共創部のるおんです。
Next.jsのApp Routerには便利な機能がたくさんありますが、今回は Parallel Routes と Intercept Routes を使用してモーダルを実装する方法をご紹介します。
Parallel Routesとは
Parallel Routes(並列ルート)は、同じレイアウト内で複数のページを同時に表示できる機能です。@folder
という命名規則でフォルダを作成することで、そのフォルダ内のコンテンツを並列に表示することができます。
例えば、ダッシュボードのような画面で、メインコンテンツと同時にサイドバーやモーダルを表示したい場合に便利です。公式ドキュメントのサンプルが非常にわかりやすいです。
Intercept Routesとは
Next.jsのIntercept Routesは、特定のルートへのナビゲーションを「インターセプト(横取り)」して、異なるUIを表示する機能です。今回のプロジェクトでは、通常のページ遷移をインターセプトしてモーダル表示を実現しています。
プロジェクトの概要
デモ動画
今回は、簡単な猫の写真ギャラリーアプリを作成します。トップページには写真の一覧が表示され、写真をクリックするとモーダルで詳細が表示される仕組みです。
注目すべきは以下の点です。
- モーダルの閉じるボタン以外にも、ブラウザの「戻る」「進む」ボタンでモーダルが閉開できる。
- モーダルの閉会でURLのpathが変わるが、Stateが保持されている
- モーダルのURLが共有可能。
- Progressive Enhancementに対応している
モーダルの閉開によってURLの遷移が行われており、ブラウザの履歴管理との統合されているにもかかわらず、これらはソフトナビゲーションでParallelに描画されるためモーダル前のStateが保持されます。リロードしたり、新しいタブでURLを開いた場合ハードナビゲーションとなるため、インターセプトした元のルートが表示されてURL共有もできるのは嬉しいですよね。
使用する主な技術:
Next.js 14
shadcn/ui(Dialogコンポーネント)
TypeScript
Tailwind CSS
実装
ディレクトリ構成
まず最初に以下が今回のディレクトリ構成です。
/
├─ app
│ ├─ _components
│ │ ├─ CatPhotoList.tsx
│ │ ├─ CatPhotoListItem.tsx
│ │ ├─ Modal.tsx
│ │ └─ Header.tsx
│ ├─ _data
│ │ └─ catPhotos.ts
│ ├─ @modal
│ │ └─ (.)photo
│ │ └─ [photoId]
│ │ └─ page.tsx
│ │ └─ default.tsx
│ └─ photo
│ └─ [photoId]
│ └─ page.tsx
├─ layout.tsx
└─ page.tsx
レイアウト作成
いまここ
/
├─ app
│ ├─ _components
│ │ ├─ CatPhotoList.tsx
│ │ ├─ CatPhotoListItem.tsx
│ │ ├─ Modal.tsx
+ │ │ └─ Header.tsx
│ ├─ _data
│ │ └─ catPhotos.ts
│ ├─ @modal
│ │ └─ (.)photo
│ │ └─ [photoId]
│ │ └─ page.tsx
│ │ └─ default.tsx
│ └─ photo
│ └─ [photoId]
│ └─ page.tsx
+ ├─ layout.tsx
└─ page.tsx
ルートのlayout.tsx
に以下のように実装したHeaderを配置しました。
Header.tsx
import Link from "next/link";
export default function Header() {
return (
<header className="border-b-2 px-8 py-4 mb-4 container mx-auto">
<Link href="/">
<h1 className="text-3xl text-center">猫の写真</h1>
</Link>
</header>
);
}
export default function RootLayout({
children,
modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
+ <Header />
{children}
</body>
</html>
);
}
ここが完成します。
写真一覧の作成
いまここ
/
├─ app
│ ├─ _components
+ │ │ ├─ CatPhotoList.tsx
+ │ │ ├─ CatPhotoListItem.tsx
│ │ ├─ Modal.tsx
│ │ └─ Header.tsx
+ │ ├─ _data
+ │ │ └─ catPhotos.ts
│ ├─ @modal
│ │ └─ (.)photo
│ │ └─ [photoId]
│ │ └─ page.tsx
│ │ └─ default.tsx
│ └─ photo
│ └─ [photoId]
│ └─ page.tsx
├─ layout.tsx
+ └─ page.tsx
まず、今回表示してあげたい猫の写真データをあらかじめ用意しておきます。本来ならこれらのデータをDBやサーバーから取得できるようにしておきますが、今回はモックしています。
猫のデータを用意
export const catPhotos = [
{
id: 1,
title: "お座りニャンコ",
path: "/images/cat_image1.jpg",
},
{
id: 2,
title: "こちらを見上げるニャンコ",
path: "/images/cat_image2.jpg",
},
{
id: 3,
title: "夢の中のニャンコ",
path: "/images/cat_image3.jpg",
},
];
次にchildrenの中身となるルートのpage.tsx
を作っていきます。
猫の写真一覧を表示するCatPhotoList.tsx
と、猫のカード単体を表示するCatPhotoListItem.tsx
を作ってそれを描画しています。
import CatPhotoList from "./_components/CatPhotoList";
import { catPhotos } from "./_data/catPhotos";
export default async function RootPage() {
return (
<main className="container mx-auto px-4">
+ <CatPhotoList photoDataList={catPhotos} />
</main>
);
}
CatPhotoList.tsx
猫の写真をループさせて表示しています。また、Stateの保持をわかりやすくするためuseState
で状態管理をするようにしています。
"use client";
import { useState } from "react";
import { PhotoData } from "../photo/[photoId]/page";
import PhotoDisplay from "./CatPhotoListItem";
export default function CatPhotoList({
photoDataList,
}: {
photoDataList: PhotoData[];
}) {
const [count, setCount] = useState(0);
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 pb-8">
{photoDataList.map((photoData) => (
<CatPhotoListItem key={photoData.id} photoData={photoData} />
))}
</div>
<div className="flex flex-col items-center gap-4 py-8 border-t">
<p className="text-xl font-semibold">カウント: {count}</p>
<button
onClick={() => setCount(count + 1)}
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 transition-opacity"
>
カウントを増やす
</button>
</div>
</>
);
}
CatPhotoListItem.tsx
Linkコンポーネントを使用して、猫の写真を押したら/photo/${photoData.id}
にソフトナビゲーションするようにしています。
"use client";
import Image from "next/image";
import Link from "next/link";
import { PhotoData } from "../photo/[photoId]/page";
type Props = {
photoData: PhotoData;
};
export default function CatPhotoListItem({ photoData }: Props) {
return (
<>
<div className="flex flex-col gap-4">
<h1 className="text-3xl text-center">{photoData.title}</h1>
<Link href={`/photo/${photoData.id}`}>
<div className="border-2 rounded-xl overflow-hidden aspect-square relative">
<Image
src={photoData.path}
alt={photoData.title}
sizes="256px"
fill
style={{
objectFit: "cover",
}}
/>
</div>
</Link>
</div>
</>
);
}
猫ちゃんたちの画像一覧が見れる見た目ができましたね。
ただ、まだこのままだと写真をクリックしても/photo/[photoId]
に遷移するだけで何も表示されません。
詳細ページ作成(/photo/[photoId])
いまここ
/
├─ app
│ ├─ _components
│ │ ├─ CatPhotoList.tsx
│ │ ├─ CatPhotoListItem.tsx
│ │ ├─ Modal.tsx
│ │ └─ Header.tsx
│ ├─ _data
│ │ └─ catPhotos.ts
│ ├─ @modal
│ │ └─ (.)photo
│ │ └─ [photoId]
│ │ └─ page.tsx
│ │ └─ default.tsx
+ │ └─ photo
+ │ └─ [photoId]
+ │ └─ page.tsx
├─ layout.tsx
└─ page.tsx
次に、各写真の詳細ページを作ります。Dynamic Routesを使用してphotoId
を受け取ってその写真を表示させます。先ほど作ったCatPhotoListItem
を再利用してあげます。
import { catPhotos } from "@/app/_data/catPhotos";
import CatPhotoListItem from "../../_components/CatPhotoListItem";
export type PhotoData = {
id: number;
title: string;
path: string;
};
type Props = {
params: {
photoId: string;
};
};
export default async function PhotoPage({ params: { photoId } }: Props) {
const photoData = catPhotos.find((photo) => photo.id === parseInt(photoId));
if (!photoData) {
return <h1>写真が見つかりません</h1>;
}
return (
<div className="mt-2 grid place-content-center">
<CatPhotoListItem photoData={photoData} />
</div>
);
}
これで写真一覧から写真を選択すると詳細画面が見ることができます。
ここまでは一般的なNext.jsを利用した画面遷移です。
それではここからはphoto/[photoId]
をインターセプトしてモーダルとして表示できるようにしたいと思います。
モーダルの作成
いまここ
/
├─ app
│ ├─ _components
│ │ ├─ CatPhotoList.tsx
│ │ ├─ CatPhotoListItem.tsx
+ │ │ ├─ Modal.tsx
│ │ └─ Header.tsx
│ ├─ _data
│ │ └─ catPhotos.ts
+ │ ├─ @modal
+ │ │ └─ (.)photo
+ │ │ └─ [photoId]
+ │ │ └─ page.tsx
+ │ │ └─ default.tsx
│ └─ photo
│ └─ [photoId]
│ └─ page.tsx
├─ layout.tsx
└─ page.tsx
モーダルとして表示できるようにModal
コンポーネントを作成します。Shadcn/uiからDialogコンポーネントをインポートして利用しています。
Modal.tsx
npx shadcn@latest add dialog
"use client"
import {
Dialog,
DialogOverlay,
DialogContent,
} from "./ui/dialog"
import { useRouter } from "next/navigation"
export function Modal({
children,
}: {
children: React.ReactNode
}) {
const router = useRouter()
const handleOpenChange = () => {
router.back()
}
return (
<Dialog defaultOpen={true} open={true} onOpenChange={handleOpenChange}>
<DialogOverlay>
<DialogContent className="overflow-y-hidden">
{children}
</DialogContent>
</DialogOverlay>
</Dialog>
)
}
このModalコンポーネントに加え、Parallel RoutesとIntercepting Routesを組み合わせることでモーダル機能を実現できます。
以下のディレクトリを見てなんだこれはと思った方も多いでしょう。これらはそれぞれParallel RoutesとIntercepting Routesのディレクトリの規則です。
│ ├─ @modal
│ │ └─ (.)photo
│ │ └─ [photoId]
│ │ └─ page.tsx
│ │ └─ default.tsx
@modal
- Parallel Routesのためのスロット
(.)photo
- Intercepting Routesのための特別な命名規則
以下解説します。
Parallel Routes
@modal
ディレクトリを作成することでParallel Routesとして明示的に登録できます。
+ ├─ @modal
│ └─ (.)photo
│ └─ [photoId]
│ └─ page.tsx
+ │ └─ default.tsx
これはセグメントとしてURLのパスには影響をしません。その際、スロットとしてレイアウトファイルに渡してあげる必要があります。詳しくはこちら
export default function RootLayout({
children,
+ modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Header />
+ {modal}
{children}
</body>
</html>
);
}
Parallel Routesを使用することで、メインコンテンツとモーダルを同時に表示できます。つまり、@modal
とchildren
(今回の場合はルートのpage.tsx
)が同時に描画されます。これにより、Partial renderingを実現できるため、写真一覧(app/page.tsx)と同時に@modal
配下のコンポーネントが描画されるので写真一覧画面のStateが保持されます。
【補足】default.tsxについて
Parallel Routesを使用する際、@modal
スロットに対応するコンテンツが存在しない場合(例:ホームページ表示時)に表示される「デフォルト」のUIを定義するために、default.tsxが必要です。
export default function Default() {
return null;
}
Intercepting Routes
app/@modal/(.)photo/[photoId]/page.tsx
を作成します。
├─ @modal
+ │ └─ (.)photo
+ │ └─ [photoId]
+ │ └─ page.tsx
│ └─ default.tsx
(.)
はマッチさせたいルート(今回の場合はapp/photo/[photoId]
)のセグメントのレベルに応じて.
を対応させます。詳しくはこちら
これにより、ソフトナビゲーションが実行された際 にはこのIntercepting Routes(app/@modal/(.)photo/[photoId]/page.tsx
)が本来のルート(app/photo/[photoId]/page.tsx
)を横取りして描画されます。一方で、リロードやJSが無効な環境においては ハードナビゲーション が実行されて本来のルート(app/photo/[photoId]/page.tsx
)が描画されます。
このIntercepting RoutesではModalコンポーネントを使用してモーダルを実現しています。
import CatPhotoListItem from "@/app/_components/CatPhotoListItem";
import { catPhotos } from "@/app/_data/catPhotos";
import { Modal } from "@/components/Modal";
type Props = {
params: {
photoId: string;
};
};
export default async function Photo({ params: { photoId } }: Props) {
const photoData = catPhotos.find((photo) => photo.id === parseInt(photoId));
if (!photoData?.id) {
return <h1 className="text-center">No Photo Found for that ID.</h1>;
}
return (
<Modal>
<CatPhotoListItem photoData={photoData} />
</Modal>
);
この状態で、写真一覧から選択すると...
このようにURLのパスがphoto/1
にも関わらず、モーダルの方が表示されていますね!
これはNext.jsが用意するLinkコンポーネントがソフトナビゲーションを実行してくれるからです。そしてその際にはIntercept Routesによってphoto/[photoId]
の画面を横取りしてモーダルを表示しています。また、Parallelで写真一覧(page.tsx)と同時にこの画面が描画されているので写真一覧画面のStateも保持されているということです。
一方で、ブラウザをリロードしたり、localhost:3000/photo/1
を共有して他のタブで見ると、それはLinkコンポーネントを介さずにシンプルにpathにアクセスするため、本来のルート(app/photo/[photoId]
)の方が表示されます。
また、JavaScriptを無効にした場合もLinkコンポーネントが通常の<a>タグとして機能するためIntercepting Routesは無視されて完全なページ遷移として動作します。
以下は、JavaScriptを有効にしたときと、無効にした時の挙動の違いです。
JavaScriptが無効なときは本来のルート(app/photo/[photoId]
)が表示されており、Progressive Enhancementとしての機能も備えていることがわかります。
おわりに
今回はNext.jsの機能であるParallel RoutesとIntercept Routesを使ってモーダルを実装してみました。Stateを保持したままURL Pathの遷移でモーダルを実装できたり、URL共有することができるモーダルを作れるのはとてもユーザー体験がよくていいと思いました!
正直Next.jsのApp Router以後のディレクトリ構成はかなり癖があり、覚えることが多いですが、Partial RenderingやErroハンドリング、今回のようなソフトナビゲーションによるモーダル表示などの機能を実現するには普通のルーティング設定では実現するのが難しいのでそこに面白さを見出して学習を続けていきたいと思います。
どなたかの参考になれば幸いです。
参考