Next.js App Router の Intercepting Routes で「URL共有できるモーダル」を作る
EP一覧からクリックするとモーダル、URL直アクセスならフルページ。Parallel Routes と Intercepting Routes でこのUXを実装し、(.)/(..) のハマりどころや dev/prod でのテスト分離まで含めて解説します。
自分のポートフォリオサイトの DJ ページ(/djdike)で、こんな UX を実装した。
- EP のジャケットをクリックすると モーダルで詳細表示(URL は
/djdike/releases/[id]に変わる) - その URL を 直接開く・リロード・SNS から流入 したときは フルページで表示
- だから モーダルの中身を URL で共有できる
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つの仕組みの組み合わせだ。
- Parallel Routes (
@modal): 1つのレイアウトに「通常コンテンツ(children)」と「モーダル(modal)」を 並行して 差し込むスロット - Intercepting Routes (
(.)): あるルートへの遷移を 横取り して、別の見た目(モーダル)で表示する
レイアウトはスロットを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 スロットがルーターの状態を持っていて、
- ソフト遷移: 「
releases/[id]へ行きたい」というクライアント遷移を検知 → 同階層にマッチする(.)releases/[id]があるので スロットがモーダルを描画(背景の一覧childrenはそのまま残る) - ハード遷移: サーバーへの新規リクエスト。クライアント遷移の文脈がないので、
@modalは対応する状態を持たずdefault.tsx(null)を描画。代わりにchildren側がreleases/[id]/page.tsx(フルページ)を出す
// app/djdike/@modal/default.tsx
// ハード遷移時のフォールバック。これが無いとスロットが解決できずエラーになる
export default function ModalDefault() {
return null
}つまり 「内側からクリックで来た=横取りしてモーダル / 外側から到達した=本物のページ」 を、フレームワークが勝手に出し分けてくれる。状態管理コードはゼロ。
ハマりどころ:(.) か (..) か
ここで盛大にハマった。Intercepting Routes のフォルダ名にはこの記法がある。
(.)… 同じ階層(..)… 1つ上の階層(..)(..)… 2つ上(...)… app ルート
最初、なんとなく「@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@slotfolders.
@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つに分けた。
playwright.config.ts… dev・高速。通常の spec 用(モーダル spec は除外)playwright.prod.config.ts…build && start。モーダル spec 専用 →npm run test:e2e:prod
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 が無く一覧も消える)を確実に弾ける。
まとめ
- Parallel Routes (
@modal) で「通常 + モーダル」を並行スロットとして持つ - Intercepting Routes (
(.)) で「内側クリックは横取りしてモーダル / 外側到達は本物のページ」を出し分け default.tsxがハード遷移時のフォールバック- 記法はファイルシステムでなく ルートセグメント で数える(スロットは透明 → 兄弟は
(.)) - 閉じるは
router.back()、スクロールロックは忘れずに - interception の E2E は prod ビルドで
状態管理コードをほぼ書かずに、URL 共有可能なモーダルというリッチな UX が手に入る。App Router の中でも実戦投入例が少ない機能だけど、ハマりどころさえ押さえれば強力だ。
ちなみに、この記事を載せている Notes 機能そのものを @next/mdx でどう作ったかは、別記事 にまとめた。