Next.jsモーダルで沼った!Parallel/Intercepting Routes活用術
多くのNext.js開発者が、モーダル実装で複雑なUI状態管理、URL共有の課題、そしてページリフレッシュ時の状態消失といった問題に直面します。特にApp Router環境では、従来のやり方では限界を感じることも少なくありません。
この記事では、Next.jsの強力なルーティング機能であるParallel RoutesとIntercepting Routesを組み合わせた、モダンで堅牢なモーダル実装方法を解説します。具体的なコード例とよくあるハマりどころ、そして設計上のベストプラクティスを通して、あなたのNext.jsモーダル実装を次のレベルへと引き上げます。
Next.js App Routerでのモーダル実装の課題
Next.jsのApp Routerが登場し、ルーティングの概念が大きく変わりました。従来のPages Routerでは、モーダルを実装する際にReact ContextやURLクエリパラメータを用いることが一般的でしたが、これには以下のような課題がありました。
- URLでの共有が困難: クエリパラメータでモーダルの状態を管理しても、そのURLを共有した際に意図通りの表示にならない、またはモーダルコンテンツがSEOに考慮されない場合がある。
- ページリフレッシュ時の状態消失: ブラウザをリフレッシュするとモーダルの状態がリセットされ、メインコンテンツのみが表示されてしまう。
- ブラウザ履歴との不整合: ブラウザの「戻る」ボタンを押した際にモーダルが閉じず、予期しないページ遷移が起こる。
- 複雑な状態管理: モーダルの表示・非表示、コンテンツの切り替えといった状態をグローバルに管理する必要があり、コードが複雑化しやすい。
これらの課題を解決し、よりネイティブなユーザー体験を提供できるのが、Next.jsのParallel RoutesとIntercepting Routesを組み合わせたモーダル実装です。
Parallel RoutesとIntercepting Routesの基本
まず、Next.js 13以降のApp Routerで提供されるこれらのルーティング機能について、その概要を理解しましょう。
Parallel Routes(並行ルート)とは
Parallel Routesは、同じレイアウト内で複数のページを同時に、または条件付きでレンダリングできる機能です。これは、ダッシュボードの複数のウィジェットや、ソーシャルサイトのフィードとチャットウィンドウのように、独立したセクションを持つUIの構築に特に有用です。
-
定義方法: フォルダ名の先頭に
@を付けて定義します(例:@modal)。この@で始まるフォルダは「スロット」と呼ばれ、URLの構造には影響しません。 - 独立性: 各スロットは独立したローディング状態やエラー状態を持つことができます。
-
クライアントサイドナビゲーション:
next/linkなどを使ったソフトナビゲーションでは、Next.jsは部分的なレンダリングを行い、他のスロットのアクティブなサブページを維持します。 -
フォールバック: フルページロード(ブラウザのリフレッシュやURL直打ち)後、現在のURLと一致しないスロットに対しては、
default.tsxファイルがレンダリングされるか、存在しない場合は404エラーとなります。このdefault.tsxが非常に重要です。
出典: Next.js 公式ドキュメント - Parallel Routes
Intercepting Routes(インターセプトルート)とは
Intercepting Routesは、現在のレイアウト内で、アプリケーションの別の部分のルートを読み込むことができる機能です。これにより、ユーザーが異なるコンテキストに切り替えることなく、特定のルートのコンテンツをモーダルとして表示するといったことが可能になります。
- ユースケース: 例えば、写真ギャラリーのフィード内で写真をクリックしたときに、フィードの上にモーダルとして写真の詳細を表示し、URLをマスクするようなケースで利用されます。
- 課題解決: URLで共有可能なモーダルコンテンツの作成、ページリフレッシュ時のコンテキスト維持、ブラウザの戻るナビゲーションでのモーダル閉じ、進むナビゲーションでのモーダル再表示といった課題を解決します。
-
定義方法: インターセプトするルートは、相対パスの表記法に似た
(.)、(..)、(..)(..)、(...)の規則で定義されます。-
(.):同じレベルのセグメントをインターセプト。 -
(..):1レベル上のセグメントをインターセプト。 -
(..)(..):2レベル上のセグメントをインターセプト。 -
(...):ルートのappディレクトリからのセグメントをインターセプト。
-
- 注意点: これらの表記はファイルシステムではなく、ルートセグメントに基づいています。
出典: Next.js 公式ドキュメント - Intercepting Routes
実践!Next.js Parallel/Intercepting Routesによるモーダル実装
ここでは、写真ギャラリーで写真をクリックするとモーダルが表示され、URLも同期される具体的な実装例を見ていきましょう。Next.jsのバージョンは13.4以降を想定しています。
ディレクトリ構造
まず、以下のディレクトリ構造を作成します。
app/
├── page.tsx // メインのギャラリーページ (localhost:3000/)
├── photo/
│ └── [id]/
│ └── page.tsx // 写真の詳細ページ(フルページ表示用) (localhost:3000/photo/1)
└── @modal/ // Parallel Routeのスロット
├── default.tsx // モーダルがアクティブでない場合のフォールバック
└── (.)photo/ // Intercepting Route (同じレベルのphotoセグメントをインターセプト)
└── [id]/
└── page.tsx // モーダル表示用の写真詳細コンポーネント (URLはlocalhost:3000/photo/1だが、表示はモーダル)
この構造のポイントは以下の2点です。
-
@modalというParallel Routeスロットを作成し、モーダルをレンダリングする場所を定義します。 -
@modal/(.)photo/[id]/page.tsxというIntercepting Routeを作成し、/photo/[id]へのアクセスをインターセプトしてモーダルとして表示します。(.)は「同じレベルのphotoセグメントをインターセプトする」という意味です。
app/layout.tsxでのParallel Routeの組み込み
app/layout.tsxはアプリケーションのルートレイアウトであり、ここに@modalスロットを組み込みます。これにより、モーダルがアプリケーションのどこからでも表示できるようになります。
import './globals.css'; // グローバルCSSをインポート (必要に応じて)
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Next.js Parallel/Intercepting Routes モーダル',
description: 'Next.js App Routerでのモダンなモーダル実装',
};
interface LayoutProps {
children: React.ReactNode;
modal: React.ReactNode; // @modal スロットに対応
}
export default function RootLayout({ children, modal }: LayoutProps) {
return (
<html lang="ja">
<body>
{children}
{modal} {/* ここに@modalスロットがレンダリングされます */}
</body>
</html>
);
}
modalというプロパティは、@modalフォルダに対応するReactノードを受け取ります。これをchildren(メインコンテンツ)の後にレンダリングすることで、モーダルがメインコンテンツの上に表示されるようになります。
app/page.tsx (ギャラリーページ)
メインのギャラリーページでは、写真の一覧を表示し、各写真へのリンクを作成します。このリンクは通常のページ遷移と同じhrefを指定します。
import Link from 'next/link';
import Image from 'next/image';
const photos = [
{ id: '1', src: '/photo1.jpg', alt: 'Photo 1' },
{ id: '2', src: '/photo2.jpg', alt: 'Photo 2' },
{ id: '3', src: '/photo3.jpg', alt: 'Photo 3' },
];
export default function GalleryPage() {
return (
<div className="gallery-container" style={{ padding: '20px' }}>
<h1 style={{ textAlign: 'center', marginBottom: '30px' }}>My Photo Gallery</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '16px' }}>
{photos.map((photo) => (
// /photo/[id]へのリンク。これがIntercepting Routesでモーダルとして表示される
<Link key={photo.id} href={`/photo/${photo.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<div style={{ border: '1px solid #ddd', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', transition: 'transform 0.2s ease-in-out', cursor: 'pointer' }}>
<Image src={photo.src} alt={photo.alt} width={300} height={200} style={{ width: '100%', height: 'auto', display: 'block' }} priority />
<p style={{ padding: '8px', margin: 0, textAlign: 'center', fontWeight: 'bold' }}>{photo.alt}</p>
</div>
</Link>
))}
</div>
</div>
);
}
ポイントは、Linkコンポーネントのhrefが/photo/${photo.id}となっている点です。これは、本来フルページで表示されるべきパスですが、Intercepting Routesの仕組みによりモーダルとして表示されます。
app/photo/[id]/page.tsx (フルページ表示用の写真詳細)
このコンポーネントは、ユーザーが直接/photo/1のようなURLにアクセスした場合や、モーダル表示中にページをリロードした場合に表示されます。
import Image from 'next/image';
import Link from 'next/link';
interface PhotoPageProps {
params: { id: string };
}
export default function PhotoPage({ params }: PhotoPageProps) {
const { id } = params;
// idに基づいて写真データを取得 (ここでは仮のデータ)
const photo = { id, src: `/photo${id}.jpg`, alt: `Photo ${id}` };
return (
<div style={{ padding: '20px', textAlign: 'center', backgroundColor: '#f9f9f9', minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<h1 style={{ fontSize: '2.5em', marginBottom: '20px', color: '#333' }}>Photo Detail: {id}</h1>
<Image src={photo.src} alt={photo.alt} width={800} height={550} style={{ maxWidth: '90%', height: 'auto', marginBottom: '30px', borderRadius: '10px', boxShadow: '0 8px 16px rgba(0,0,0,0.2)' }} priority />
<p style={{ fontSize: '1.2em', color: '#555', marginBottom: '40px' }}>
This is the full page view of photo {id}. You can share this URL directly.
</p>
<Link href="/" style={{ display: 'inline-block', padding: '12px 25px', backgroundColor: '#0070f3', color: 'white', borderRadius: '8px', textDecoration: 'none', fontSize: '1.1em', fontWeight: 'bold', transition: 'background-color 0.3s ease' }}>
Back to Gallery
</Link>
</div>
);
}
このコンポーネントは、モーダルとは完全に独立して動作する通常のページです。
app/@modal/default.tsx (Parallel Routeのフォールバック)
Parallel Routeのスロットには、必ずdefault.tsxが必要です。モーダルがアクティブでない場合や、ハードナビゲーション時にこのファイルがレンダリングされます。通常はnullを返して何も表示しないようにします。
export default function DefaultModal() {
return null; // モーダルがアクティブでない場合は何も表示しない
}
app/@modal/(.)photo/[id]/page.tsx (モーダル表示用の写真詳細)
これが実際にモーダルとして表示されるコンポーネントです。'use client'ディレクティブを忘れずに記述し、クライアントコンポーネントとしてマークします。
'use client'; // クライアントコンポーネントとしてマーク
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { MouseEvent } from 'react';
interface PhotoModalPageProps {
params: { id: string };
}
export default function PhotoModalPage({ params }: PhotoModalPageProps) {
const router = useRouter();
const { id } = params;
// idに基づいて写真データを取得 (ここでは仮のデータ)
const photo = { id, src: `/photo${id}.jpg`, alt: `Photo ${id}` };
// モーダルを閉じる処理
const handleClose = () => {
router.back(); // ブラウザの履歴を戻ることでモーダルを閉じる
};
// モーダルコンテンツ内でのクリックがオーバーレイに伝播しないようにする
const handleContentClick = (e: MouseEvent) => {
e.stopPropagation();
};
return (
<div
className="modal-overlay"
onClick={handleClose} // オーバーレイクリックでモーダルを閉じる
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000, // 他のコンテンツの上に表示
animation: 'fadeIn 0.3s ease-out forwards', // フェードインアニメーション
}}
>
<div
className="modal-content"
onClick={handleContentClick} // コンテンツ内のクリックは伝播させない
style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
position: 'relative',
maxWidth: '90%',
maxHeight: '90%',
overflow: 'auto',
textAlign: 'center',
animation: 'slideIn 0.3s ease-out forwards', // スライドインアニメーション
}}
>
<h1 style={{ marginTop: 0, color: '#333' }}>Photo Modal: {id}</h1>
<Image src={photo.src} alt={photo.alt} width={600} height={400} style={{ maxWidth: '100%', height: 'auto', display: 'block', margin: '0 auto 20px', borderRadius: '5px' }} priority />
<button
onClick={handleClose}
style={{
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '1em',
transition: 'background-color 0.3s ease',
}}
>
Close
</button>
</div>
{/* CSSアニメーションの定義 */}
<style jsx global>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
</div>
);
}
このコンポーネントの重要な点は以下の通りです。
-
'use client':useRouterフックを使用するため、クライアントコンポーネントとして明示的に宣言します。 -
useRouter().back(): モーダルを閉じる際にrouter.back()を呼び出すことで、ブラウザの履歴を1つ戻し、モーダルを開く前の状態に戻します。これにより、ブラウザの「戻る」ボタンとモーダルの閉じる動作が同期されます。 -
e.stopPropagation(): モーダルコンテンツ内のクリックイベントが、オーバーレイのクリックイベントに伝播しないように防ぎます。
動作確認のための補足
-
publicフォルダにphoto1.jpg,photo2.jpg,photo3.jpgなどの画像ファイルを配置してください。 -
npm run devで開発サーバーを起動し、http://localhost:3000にアクセスしてください。 - ギャラリーの画像をクリックするとモーダルが表示され、URLが
/photo/[id]に変わることを確認してください。 - モーダルを閉じるとURLが元の
/に戻り、ブラウザの「戻る」ボタンでもモーダルが閉じることが確認できます。 - モーダル表示中にページをリロードすると、フルページの写真詳細が表示されることを確認してください。
よくあるエラー・ハマりどころと回避策
Next.js Parallel/Intercepting Routesを使ったモーダル実装でつまずきがちなポイントとその解決策を解説します。
1. Intercepting Routesがトリガーされない、または期待通りに動作しない
-
ハマりどころ: インターセプトするルートのセグメント構造が、ターゲットルートのセグメント構造と正確に一致していない場合に発生します。例えば、ターゲットが
/photos/[id]/detailsの場合、インターセプターは(.)photos/[id]/detailsである必要があります。また、ファイルシステム上の階層とルートセグメントの階層を混同している場合があります。@slotフォルダはルートセグメントの階層には影響しません。 -
回避策: ターゲットルートのセグメント構造とインターセプトするルートのパスが正確に一致しているか確認します。特に、
(.)、(..)、(..)(..)、(...)の表記が正しいルートセグメントの階層を指しているか慎重に確認しましょう。開発サーバーを再起動することで解決する場合もあります。ファイル名やフォルダ名をよく見直してください。
2. ページリフレッシュ時に404エラーが発生する、またはモーダルが表示されない
-
ハマりどころ: Parallel Routeのスロットに
default.tsxファイルが存在しない場合に発生します。ハードナビゲーション(ブラウザのリフレッシュやURL直打ち)時にNext.jsがそのスロットのアクティブな状態を判断できず、何もレンダリングされないか404エラーになることがあります。 -
回避策: 各Parallel Routeフォルダ(例:
@modal)には、必ずdefault.tsxファイルを含めてください。このファイルは、モーダルがアクティブでない場合や、ハードナビゲーション時にフォールバックとしてnullを返すようにします。これにより、URLが直接アクセスされた場合でも、メインコンテンツが正しく表示され、モーダルスロットは空の状態になります。
3. モーダル内のフォーム送信やクライアントコンポーネントの動作が不安定
- ハマりどころ: Intercepting Routesで表示されるモーダルは、ソフトナビゲーション時に現在のコンテキストを維持しますが、ハードナビゲーション時には通常のページとしてレンダリングされます。この違いを考慮せずに、モーダルとフルページの両方で同じコンポーネントを使い回すと、予期せぬ動作につながる可能性があります。例えば、モーダル内のクライアントコンポーネントが、フルページアクセス時にサーバーコンポーネントとして扱われようとする、またはその逆でエラーになることがあります。
-
回避策: モーダルとフルページの両方のバージョンが本番環境で動作するように、それぞれを独立してテストし、必要に応じて異なるUIやロジックを持つようにします。モーダル内のフォームやインタラクティブな要素は、
'use client'ディレクティブを適切に利用し、クライアントコンポーネントとして明示的にマークしてください。また、データのフェッチなどはサーバーコンポーネントで行い、インタラクティブな部分のみをクライアントコンポーネントに委ねるなど、サーバー/クライアントコンポーネントの役割分担を明確にしましょう。
4. useRouterがクライアントコンポーネント以外で使われているエラー
-
ハマりどころ:
next/navigationからインポートされるuseRouterフックは、クライアントコンポーネントでのみ使用可能です。サーバーコンポーネント内で使用しようとするとエラーが発生します。 -
回避策:
useRouterを使用するコンポーネントのファイル冒頭に'use client';ディレクティブを記述し、クライアントコンポーネントとして明示的にマークしてください。
設計上のトレードオフとベストプラクティス
Next.js Parallel/Intercepting Routesをモーダルに活用する際の設計上の考慮事項と推奨されるアプローチです。
トレードオフ
- 複雑性の増加: Parallel RoutesとIntercepting Routesを組み合わせることで、ルーティングの柔軟性は向上しますが、ファイル構造やルーティングロジックが複雑になる可能性があります。特に、複数のParallel Routesや深い階層のIntercepting Routesを使用する場合に顕著です。開発チームの学習コストも考慮する必要があります。
- 学習コスト: Next.jsのApp Routerにおけるルーティングの概念(特にParallel/Intercepting Routes)を深く理解する必要があり、従来のPages Routerに慣れている開発者にとっては学習コストがかかります。
- SEOとの兼ね合い: モーダルコンテンツをURLで共有できる利点がある一方で、リフレッシュ時にフルページが表示される挙動がユーザーにとって混乱を招く可能性もあります。ただし、コンテンツ自体は通常のページとして存在するため、SEO上の問題は少ないと考えられます。
ベストプラクティス
-
default.tsxの活用: Parallel Routeのスロットには常にdefault.tsxを含め、未マッチのケースやハードナビゲーション時のフォールバックを適切に処理しましょう。これにより、予期せぬ404エラーを防ぎ、安定したユーザー体験を提供できます。 - URLの一貫性: モーダルとフルページの両方で、コンテンツが同じURLでアクセスできるように設計します。これにより、URLの共有性やリフレッシュ時のコンテキスト維持が実現されます。ユーザーがモーダル表示中にURLをコピーして共有したり、ページをリロードしたりしても、期待通りのコンテンツが表示されます。
-
ナビゲーションの考慮: モーダルを閉じる際には
router.back()を使用し、ブラウザの履歴と同期させることで、ネイティブなブラウザの戻る/進むボタンの動作と一貫性を持たせましょう。これにより、ユーザーは直感的にモーダルを操作できます。 -
ローディングとエラー状態の定義: モーダルも独立したサーバーコンポーネントとしてデータをフェッチしレンダリングされるため、ローディングUI(
loading.tsx)やエラーバウンダリ(error.tsx)を適切に定義し、ユーザーエクスペリエンスを向上させることが可能です。 -
明確なファイル命名規則:
@modalのようなスロット名や、(.)photoのようなインターセプトの表記を明確にし、チームメンバーが理解しやすいようにしましょう。コメントやドキュメントで意図を補足することも有効です。 - UI/UXの向上: メインコンテンツのコンテキストを維持したまま、モーダルで追加コンテンツを表示することで、シームレスなUI遷移と優れたユーザーエクスペリエンスを提供できます。特に、ユーザーが現在のページから離れることなく詳細情報を確認できる点で優れています。
まとめ
Next.jsのParallel RoutesとIntercepting Routesを組み合わせることで、従来のモーダル実装が抱えていたURL共有、リフレッシュ時の状態維持、ブラウザ履歴との同期といった課題を根本的に解決できることがお分かりいただけたでしょうか。
本記事では、写真ギャラリーを例に具体的な実装方法、よくあるハマりどころとその回避策、そして設計上のトレードオフとベストプラクティスを解説しました。これらの機能を理解し活用することで、よりリッチでユーザーフレンドリーなWebアプリケーションを構築することが可能になります。
この強力なルーティング機能を活用し、あなたのNext.jsプロジェクトでモダンなモーダルを実装してみてください。
次の一歩: Next.js公式ドキュメントで、Parallel RoutesとIntercepting Routesの詳細な仕様や、さらに高度なユースケース(例: 認証モーダル、ダッシュボードの複雑なレイアウトなど)を確認してみましょう。
Next.js 公式ドキュメント - Parallel Routes
Next.js 公式ドキュメント - Intercepting Routes