Notes

Next.js App Router の Intercepting Routes で「URL共有できるモーダル」を作る

EP一覧からクリックするとモーダル、URL直アクセスならフルページ。Parallel Routes と Intercepting Routes でこのUXを実装し、(.)/(..) のハマりどころや dev/prod でのテスト分離まで含めて解説します。

#Next.js#App Router#Intercepting Routes#Parallel Routes#TypeScript#Playwright

自分のポートフォリオサイトの DJ ページ(/djdike)で、こんな UX を実装した。

Instagram や X で写真をクリックしたときのあの挙動だ。これを自前の状態管理やモーダルライブラリではなく、Next.js App Router の標準機能(Parallel Routes + Intercepting Routes) だけで実現した記録。

完成形のファイル構成

app/djdike/
├── page.tsx                       # EP一覧
├── layout.tsx                     # {children} と {modal} を受け取る
├── @modal/                        # ← Parallel Routes(モーダル用スロット)
│   ├── default.tsx                # ハード遷移時のフォールバック(null)
│   └── (.)releases/[id]/page.tsx  # ← Intercepting Route(モーダル版)
└── releases/[id]/
    ├── page.tsx                   # フルページ版
    ├── loading.tsx
    └── error.tsx

ポイントは2つの仕組みの組み合わせだ。

レイアウトはスロットを2つ受け取るだけ

// app/djdike/layout.tsx
export default function DjdikeLayout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}

@modal というフォルダ名が、そのまま modal という props 名になる。これが Parallel Routes の「名前付きスロット」。

いちばん大事な「いつマッチするか」

ここが Intercepting Routes の核心で、最初に腹落ちさせるべきところ。

アプリ内のソフトナビゲーション(クリック遷移)でそのパスへ移動したときだけ横取りする。 ハードナビゲーション(直アクセス・リロード・新規タブ)では横取りせず、本来のページを出す。

操作横取り?結果
/djdike でカードをクリックモーダル(URL は /djdike/releases/xxx
そのモーダル状態で F5 リロードフルページ
/djdike/releases/xxx を直打ち / SNS から流入フルページ

仕組みとしては、@modal スロットがルーターの状態を持っていて、

// app/djdike/@modal/default.tsx
// ハード遷移時のフォールバック。これが無いとスロットが解決できずエラーになる
export default function ModalDefault() {
  return null
}

つまり 「内側からクリックで来た=横取りしてモーダル / 外側から到達した=本物のページ」 を、フレームワークが勝手に出し分けてくれる。状態管理コードはゼロ。

ハマりどころ:(.)(..)

ここで盛大にハマった。Intercepting Routes のフォルダ名にはこの記法がある。

最初、なんとなく「@modal の中にあるんだから1つ上だろう」と (..)releases にした。動かない。 クリックしても横取りされず、普通にフルページへ遷移してしまう。

原因はビルドのルートマニフェストを見て判明した。

"page": "/djdike/(..)releases/[id]",
"regex": "^/djdike/\\(\\.\\.\\)releases/([^/]+?)(?:/)?$"

(..)インターセプトとして解釈されず、(\.\.) というただの文字列パス になっていた。

なぜ (.) が正解なのか

公式ドキュメントに決定的な一文がある。

The (..) convention is based on route segments, not the file-system. It does not consider @slot folders.

@modalスロットであってパスセグメントではない。つまりセグメント的には透明で、releases@modal同じ階層(兄弟) という扱いになる。だから正解は「同じ階層」を意味する (.)releases

公式の @auth/(.)login の例とまったく同じ構造だった。(..)/djdike の外(存在しない /releases)を指すため、ソフト遷移してもマッチせず通常遷移に転落していたわけだ。

app/djdike/@modal/(.)releases/[id]/page.tsx   ← これが正

教訓: ファイルシステムの見た目で数えるな。ルートセグメントで数えろ。スロットは透明。

モーダル本体はネイティブ <dialog>

モーダルは自前 div + オーバーレイではなく、ネイティブ <dialog>showModal() を使った。フォーカストラップ・ESC キー・背景の inert 化をブラウザ標準に任せられて堅牢だから。

'use client'
export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const dialogRef = useRef<HTMLDialogElement>(null)
 
  useScrollLock() // 後述
 
  useEffect(() => {
    const dialog = dialogRef.current
    if (dialog && !dialog.open) dialog.showModal()
  }, [])
 
  const handleClose = () => router.back() // 閉じる = URL を一覧へ戻す
 
  return (
    <dialog ref={dialogRef} onClose={handleClose} /* 背景クリックでも close */>
      {children}
    </dialog>
  )
}

閉じる操作を router.back() にしているのがミソ。モーダルは「/djdike の上に /djdike/releases/xxx を積んだ」状態なので、戻れば自然に一覧 URL へ復帰する。ブラウザの「戻る」ボタンでも同じように閉じる。

地味だけど大事:背景スクロールのロック

<dialog> は背景を inert(操作不可)にはするが、スクロール自体は止めない。モーダルを開いたまま裏の一覧がスクロールできてしまう。なので body 側で明示的にロックした。単一責任のフックに切り出して、将来のドロワー等でも使い回せるようにしている。

// hooks/useScrollLock.ts
export function useScrollLock(locked = true) {
  useEffect(() => {
    if (!locked) return
    const { body } = document
    // スクロールバーが消えることで起きる横ズレを、その幅ぶんの padding で相殺する
    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
    const prevOverflow = body.style.overflow
    const prevPadding = body.style.paddingRight
    body.style.overflow = 'hidden'
    if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`
    return () => {
      body.style.overflow = prevOverflow
      body.style.paddingRight = prevPadding
    }
  }, [locked])
}

URL で共有できる = OGP も EP ごとに出す

モーダルの中身を URL で共有できるなら、SNS カードも EP ごとに出したい。generateMetadata でアルバム情報から動的に生成する。

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params
  const album = await getAlbumById(id)
  const cover = album.images[0]?.url
  return {
    title: `${album.name} - DJ DIKE`,
    openGraph: {
      title: album.name,
      images: cover ? [cover] : undefined,
      type: 'music.album',
    },
    twitter: { card: 'summary_large_image', images: cover ? [cover] : undefined },
  }
}

generateMetadata は Server Component と同じく async でデータ取得できるので、「EP ごとに違うジャケット画像のカード」が作れる。generateStaticParams と組み合わせれば、一覧にある EP は ビルド時に静的生成 される。

テストの落とし穴:Intercepting Routes は dev で安定しない

仕上げに Playwright で「クリック → モーダル表示 → ESC で復帰」の E2E を書いた。が、next dev で実行すると モーダルが出ずフルページに転落 して落ちる。

原因は dev のオンデマンドコンパイル。インターセプトルートは初回アクセス時にコンパイルされるため、最初のソフト遷移がそれに間に合わず、通常遷移にフォールバックしてしまう。手動だと prefetch が効いて気づかないが、自動テストの即時クリックでは露呈する。

プロダクションビルド(next build && next start)では事前コンパイル済みなので決定的に動く。 そこで設定ファイルを2つに分けた。

E2E はインターセプト成立をこう検証している。

await albumLinks.first().click()
await expect(page).toHaveURL(/\/djdike\/releases\//)
await expect(page.getByRole('dialog')).toBeVisible()
// 背景の一覧が DOM に残っている = 横取り成立(フルページ遷移なら一覧は消える)
expect(await albumLinks.count()).toBeGreaterThan(0)

「dialog が出る」だけでなく「背景の一覧が残っている」も確認するのがポイント。通常遷移に転落したケース(dialog が無く一覧も消える)を確実に弾ける。

まとめ

状態管理コードをほぼ書かずに、URL 共有可能なモーダルというリッチな UX が手に入る。App Router の中でも実戦投入例が少ない機能だけど、ハマりどころさえ押さえれば強力だ。

ちなみに、この記事を載せている Notes 機能そのものを @next/mdx でどう作ったかは、別記事 にまとめた。