写真の一覧から画像をクリックすると、その場でモーダルが開いて拡大表示され、同じ URL をブラウザで直接開くと写真ページ全体が表示される。SNS の画像ビューアーでよく見るこの挙動は、Next.js App Router の Intercepting Routes(インターセプトルート)を使うと自然に実装できます。この記事では、Intercepting Routes が「ルートへの遷移を横取りする」とはどういうことかという基本の考え方から、(.)folder などの記法の規約、Parallel Routes と組み合わせた定番のモーダル実装、ソフトナビゲーションとハードナビゲーションで挙動が変わる仕組み、そしてつまずきやすいポイントまでを、具体的なディレクトリ構成とあわせて解説します。
目次
Intercepting Routes とは何か
Intercepting Routes は、あるルートへの遷移を「横取り(intercept)」して、ページ全体を切り替える代わりに、現在のレイアウトの中に別の内容を表示する App Router の仕組みです。多くの場合、この「別の内容」はモーダルです。アプリ内のリンクをたどってそのルートへ移動したときだけ横取りが働き、モーダルとして開きます。一方で、同じ URL をブラウザのアドレスバーに直接入力したり、その状態でリロードしたりすると、横取りは行われず、本来のページ全体が表示されます。
つまり同じ URL に対して、「アプリ内で遷移してきたのか」「外から直接来たのか」で見せ方を変えられるのが Intercepting Routes の特徴です。1枚の写真に対して /photo/123 という共有可能な URL を持たせつつ、一覧からクリックしたときはページ遷移せずにモーダルで軽く見せる、という両立ができます。
(.) や (..) の記法の読み方
どのルートを横取りするかは、フォルダ名の先頭に付ける括弧付きの記号で指定します。ファイルシステムの相対パスにおける ../ に似ていますが、対象はファイルの階層ではなく URL のセグメント(パスの区切り)を基準に数える点が大きな違いです。規約は次のとおりです。
| 記法 | 横取りする対象 |
|---|---|
(.)folder | 同じ階層のセグメントをインターセプトする |
(..)folder | 1つ上の階層のセグメントをインターセプトする |
(..)(..)folder | 2つ上の階層のセグメントをインターセプトする |
(...)folder | app ルート(プロジェクトの最上位)からインターセプトする |
ここで注意したいのは、(..) が数えているのはフォルダの階層ではなく ルートセグメントの階層だということです。Route Groups(丸括弧だけのフォルダ)のように URL に現れないフォルダは、この数え方には含まれません。フォルダの見た目の深さと URL の深さがずれることがあるため、「いくつ上か」は必ず生成される URL のパスを基準に考えてください。ここが Intercepting Routes で最初に迷いやすいところです。
写真ギャラリーでの典型的な使い方
もっとも代表的なユースケースが写真ギャラリーです。一覧ページ /feed から個々の写真ページ /photo/123 へ、アプリ内のリンクで移動したときはモーダルで開き、/photo/123 を直接開いたときは写真ページ全体を表示する、という動きを作ります。この定番パターンは、Intercepting Routes だけでなく Parallel Routes(パラレルルート)と組み合わせて実現します。
Parallel Routes は、@modal のように @ で始まる「スロット」を使って、同じレイアウトの中に複数の内容を並行して差し込む仕組みです。モーダル表示用のスロット @modal を用意し、その中にインターセプト用のルートを置きます。全体のディレクトリ構成は次のようになります。
app/
├─ layout.tsx
├─ feed/
│ └─ page.tsx → /feed(写真一覧)
├─ photo/
│ └─ [id]/
│ └─ page.tsx → /photo/123(写真ページ全体)
└─ @modal/ ← モーダル用のスロット
├─ default.tsx ← スロットの既定値(何も表示しない)
└─ (.)photo/
└─ [id]/
└─ page.tsx ← /photo/[id] を横取りしてモーダル表示
ポイントは @modal/(.)photo/[id]/page.tsx です。@modal はスロットなので URL のセグメントには数えません。そのため @modal の中から見た photo は「同じ階層」にあたり、(.)photo と書きます。このファイルが /photo/123 への遷移を横取りし、モーダルの中身として表示されます。
レイアウトとスロットを組み立てる
Parallel Routes のスロットは、レイアウトの props として受け取ります。@modal スロットは modal という名前の props になるので、app/layout.tsx で children と一緒に受け取って描画します。
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
{children}
{/* 横取りされたときだけ中身が入るスロット */}
{modal}
</body>
</html>
);
}
次に、スロットの既定値となる default.tsx を用意します。@modal スロットが横取りされていない通常の状態では、モーダルは何も表示されないようにしたいので、null を返します。この default.tsx がないと、リロードした際にスロットのレンダリング対象が見つからず 404 になってしまうため、Parallel Routes を使うときは必須のファイルです。
export default function Default() {
// 通常時(横取りされていないとき)は何も表示しない
return null;
}
最後に、横取りしたときにモーダルとして表示される中身を @modal/(.)photo/[id]/page.tsx に書きます。ここでは簡単なモーダルの枠だけを示します。実際にはオーバーレイをクリックしたら閉じる、といった処理を router.back() などで加えます。
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="modal-overlay">
<div className="modal-content">
{/* 一覧から遷移したときだけモーダルで表示される写真 */}
<h2>写真 {id}</h2>
<img src={`/photos/${id}.jpg`} alt={`写真 ${id}`} />
</div>
</div>
);
}
ソフトナビゲーションとハードナビゲーションで挙動が変わる仕組み
Intercepting Routes の挙動を理解する鍵は、遷移の種類の違いです。Next.js の遷移は大きく2つに分けられます。
1つ目はソフトナビゲーションです。これは <Link> コンポーネントや router.push() による、アプリ内のクライアント側の遷移を指します。/feed の一覧に置いた <Link href="/photo/123"> をクリックしたときがこれにあたります。このときだけ Intercepting Routes が働き、@modal/(.)photo/[id]/page.tsx が横取りして、一覧の上にモーダルとして写真を重ねて表示します。URL は /photo/123 に変わりますが、背後の一覧ページは残ったままです。
2つ目はハードナビゲーションです。アドレスバーに /photo/123 を直接入力する、リンクを新しいタブで開く、モーダルが開いた状態でリロードする、といったサーバーへの完全なリクエストを伴う遷移がこれです。このときは横取りは行われず、app/photo/[id]/page.tsx の本来の写真ページ全体が表示されます。共有された URL を受け取った人がきちんと1枚のページとして写真を見られるのは、このためです。
同じ /photo/123 という URL でも、「アプリ内でソフトに遷移したか」「外からハードに到達したか」で表示が切り替わる。この二面性こそが Intercepting Routes の本質です。
モーダルが表示されない・404 になるとき
実装してみると、思ったようにモーダルが出なかったり、リロードで 404 になったりすることがあります。原因はいくつかのパターンに分かれます。
default.tsx を置き忘れている
もっとも多いのが、@modal スロットに default.tsx を用意していないケースです。Parallel Routes のスロットは、現在の URL に対応する内容が見つからないときのフォールバックとして default.tsx を必要とします。これがないと、モーダルを開いた状態でリロードしたときにスロットが解決できず 404 になります。null を返すだけの default.tsx を必ず置いてください。
括弧の階層の数え方がずれている
(.) と (..) のどちらを使うべきかを取り違えると、横取りが働かずに素通りしてしまいます。前述のとおり、階層はフォルダの深さではなく URL セグメントで数えます。@modal のようなスロットや Route Groups は URL に現れないので数えません。今回の例では @modal の中の photo は一覧と同じ階層にあたるため (.)photo ですが、構成が変わればここは (..) になることもあります。うまく横取りされないときは、まず生成される URL を書き出して、そこから何セグメント分さかのぼる必要があるかを数え直すのが近道です。
横取り元のページが存在しない
Intercepting Routes は、あくまで既存のルートを横取りする仕組みです。横取り先である app/photo/[id]/page.tsx(ページ全体を表示する本来のルート)自体を用意しておく必要があります。これがないと、ハードナビゲーション時に表示すべきページがなく、共有 URL を開いても写真が見られません。モーダル用の @modal/(.)photo/... と、本体の photo/[id]/... は必ずペアで用意すると覚えておきましょう。
まとめ
Intercepting Routes は、あるルートへの遷移を横取りして、ページ全体を切り替える代わりに現在のレイアウト内へ別の内容(多くはモーダル)を表示する App Router の仕組みです。アプリ内のソフトナビゲーションでは横取りしてモーダルを開き、URL の直接オープンやリロードといったハードナビゲーションでは本来のページ全体を表示するため、共有可能な URL とモーダル表示を同じルートで両立できます。横取りの対象は (.)/(..)/(...) の記法で指定し、その階層は URL セグメント基準で数える点に注意が必要です。写真ギャラリーのようなユースケースでは、Parallel Routes の @modal スロットと default.tsx を組み合わせるのが定番の構成です。モーダルが出ない・404 になるといったつまずきは、default.tsx の有無や括弧の階層の数え方を見直すと解決することが多いので、まずはそこを確認してみてください。