Parallel Routes + Intercepting RoutesでURL同期モーダルを作る

Next.js(App Router)で便利なのにクセの強さに振り回されたParallel RoutesとIntercepting Routes の組み合わせについて書きます。

Xとか見てると「投稿一覧で写真をタップしたらURLは変わるのにモーダルで開き、リロードしたら詳細ページ単体で開く」みたいな実装ありますよね。あれを作ろうとして丸一日溶けたのでやったことを残しておきます。


作りたかったもの

こんな感じのフォトギャラリーを想定してました。

  • /gallery にアクセス → 画像一覧が表示される
  • /gallery/photo/1 をクリック → URL が変わって、一覧の上にモーダルがふわっと浮かぶ
  • そこからリロード、または直リンク → モーダルじゃなく、詳細ページ単体が全画面で開く

Next.js の App Router ならステート(isOpen とか)なしで、URL と完全に同期した形でいけるはずでした。


ディレクトリ構造

公式ドキュメント読んで、こんな構成にしました。

src/app/
└── gallery/
    ├── @modal/              # Parallel Route(モーダル用)
    │   ├── (.)photo/        # Intercepting Route(同階層の photo をインターセプト)
    │   │   └── [id]/
    │   │       └── page.tsx
    │   └── default.tsx      # モーダル閉じてる時の空コンポーネント
    ├── photo/               # 直リンク・リロード用の通常ルート
    │   └── [id]/
    │     └── page.tsx
    ├── layout.tsx           # @modal と children を並べる
    └── page.tsx             # 画像一覧

gallery/layout.tsx

Parallel Routesを使って、propsから children(一覧)と modal(モーダル)を受け取って並べます。

import React from 'react';

export default function GalleryLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div className="relative">
      {children}
      {modal}
    </div>
  );
}

gallery/@modal/default.tsx

ここ結構重要で、モーダルが表示されてない時は何も描画しないnullを返すファイルが必要です。ないと Next.jsが「ルート未定義」で404吐いたりします。

export default function Default() {
  return null;
}

詰まったところ

実装してみたら、いろいろ壁に当たりました。

注意①:インターセプトが効かない

一覧から <Link href="/gallery/photo/1"> をクリックしても、モーダルが開かず普通に全画面の詳細ページに遷移しちゃいました。

原因と対策:

インターセプト記法の (.)photo の「階層指定」を勘違いしてました。ドキュメントで「(..) は 1 つ上の階層」って書いてあるから、@modal から見て上に行くのかな?みたいに思ってたんですよ。

でも実は、スロット(@modal みたいなやつ)は URL の階層にカウントされません。 つまり、@modal を無視して、ベースの gallery/ から見た相対位置で計算する必要があります。gallery/ から見て photo/ は同じ階層だから、正解は (.)photo でした。

それでも動かなかったのは、開発サーバーのキャッシュのせい。フォルダ構造を変えたときは .next ディレクトリを削除して、サーバー再起動すれば OK でした。構造いじるたびにこれやらないと厳しい。


注意②:「戻る」でモーダル閉じたのに画面が残る

モーダルの閉じるボタンにrouter.back()を仕込んで、ブラウザの戻るボタンでもモーダル閉じられるようにしました。

本来ならrouter.back()で URLが/gallery に戻ると、Next.js が自動で @modal スロットに default.tsxnullを返すやつ)を適用して、ステートなしで自然にモーダルが消えるはず。Parallel Routes の美しいところですね。

……だったんですが、URLは戻るのに、画面のモーダルが消えずに残り続けるという奇妙な現象が。App Routerの履歴管理のクセか何かですかね。

対策(安全策):

ステートレスを諦めて、コンポーネント側にisOpenステート持たせることにしました。確実に対処するために。あとNext.js 15からは paramsが非同期(Promise)なので、use clientではReact.use()で展開します。

// gallery/@modal/(.)photo/[id]/page.tsx
'use client';

import { useRouter } from 'next/navigation';
import { useState, use } from 'react';

export default function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
  const router = useRouter();
  const { id } = use(params);

  const [isOpen, setIsOpen] = useState(true);

  const handleClose = () => {
    setIsOpen(false);
    router.back();
  };

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div className="bg-white p-6 rounded-lg max-w-lg w-full">
        <h2>写真 ID: {id}</h2>
        <button onClick={handleClose} className="mt-4 px-4 py-2 bg-blue-500 text-white rounded">
          閉じる
        </button>
      </div>
    </div>
  );
}

ちなみに router.back()より、router.push('/gallery', { scroll: false })で明示的に一覧に飛ばしちゃう方が、履歴状態に左右されず安定します。試してみてください。


注意③:アニメーションが効かない

モーダルにふわっとフェードインするアニメーション付けたくて、Tailwind CSS使おうとしました。でも直リンクのときは全画面表示にしたいから、モーダル用と全画面用で同じ詳細コンポーネント使い回してたんです。

そのせいで、モーダル専用のアニメーション(背景の黒いレイヤーとか)どこに仕込むのか混乱しました。

対策:

コンポーネント共通化を中身のコンテンツだけに留めて、外枠(モーダル背景やアニメーション)は gallery/@modal/(.)photo/[id]/page.tsxで完全に切り分けました。

  • モーダル側: 背景フェードイン + 中央コンテンツのポップアップアニメーション
  • 全画面側: 普通にページレイアウトに配置

役割をきっちり分けたら、コードが整理されてアニメーションも意図通り動きました。


思ったこと

Parallel & Intercepting Routes は一度組んじゃえば「UX が爆上がりする便利機能」だと思います。ただ引っかかりやすいのはこの3点:

  1. フォルダ構造を変えたら .next を削除してサーバー再起動する
  2. スロット(@modal など)は階層カウント(...)に含めない
  3. 挙動がおかしい時は、安全策としてローカルステート制御も視野に入れる

最初は普通にuseStateで管理した方が 100 倍楽では…って心折れかけましたけど、リロードしてもちゃんとその写真が開く感覚を体験するとやってよかったって思います。