はじめに
Parallel Routes + Intercepting Routes を使うと、一覧から詳細へ遷移しても 一覧をモーダルの背後に残せる ため、スクロール位置を自然に保持できます。
さらに、詳細ページは直リンクでも開けるので、UX と SEO を両立できます。
他の方法も検討しましたが、とても便利に使えるのでおすすめです。
この記事では、そのParallel Routes, Intercepting Routes についてと、その簡単な実装方法、検討した他の方法を記載します。
Parallel Routes とは
結論: 1つのURL(同じルート)に対して、複数の “枠(スロット)” を同時に描画できる仕組みです。
Parallel Routes は @xxx という スロットを作れます。
例:/samples の画面を
- メイン領域:一覧
- 追加領域:モーダル(必要なときだけ)
のように分けられます。
[ ポイント ]
- メイン(一覧)をそのまま表示したまま
- 追加スロットに詳細を「上乗せ」できる
結果として、一覧コンポーネントがアンマウントされにくくなり、スクロール位置が維持される構造を作れます。
ここではシンプルに説明していますが、自分は 公式ドキュメント や こちらの記事 を参考にさせていただきましたので、詳しく理解したい方はぜひお読みください。
Intercepting Routes とは
結論: あるルートへの遷移を、**別の場所(別スロット)で “横取りして表示”**できる仕組みです。
Intercepting Routes では (.) や (..) といった記法で、「本来表示されるページ」をモーダル枠で表示できます。
今回の要点はこれです:
- URL は
/samples/:idに変わる(=正しいURLになる) - でも画面は「ページ遷移」ではなく「モーダルを開く」ように見える
- 直リンクで
/samples/:idを開いたときは、通常の詳細ページとして表示できる
つまり、UIはモーダル、URLは詳細ページという “良いとこ取り” ができます。
こちらもシンプルに説明していますが、自分は 公式ドキュメント や こちらの記事 を参考にさせていただきましたので、詳しく理解したい方はぜひお読みください。
モーダル形式にするとなぜスクロール位置が保持できるのか
結論: 一覧ページ(背景)が 描画されたまま残り、詳細はその上に “重ねる” だけなので、ブラウザのスクロール位置が変わらないからです。
通常のページ遷移だと、
-
/samplesのコンポーネントがアンマウント -
/samples/:idがマウント - 戻ると再レンダリングやデータ再取得が発生しやすい
- スクロールが先頭に戻りがち
という流れになります。
一方、モーダル(オーバーレイ)だと、
- 背景(一覧)が 残る
- 「詳細」は別スロットで 追加描画されるだけ
- 戻ると “モーダルが消えるだけ” に近い
ので、一覧のスクロール位置が自然に維持されます。
自分が関わっていたページは、Load More (もっと見る) 機能みたいなのがあり、その時には特にこのメリットが活用できると思います。
SEO 的に問題はない?
結論: 問題になりにくいです。 URL が /samples/:id として成立し、直リンクでも詳細ページを表示できるためです。
SEO観点の要点:
- 一覧からモーダルで開いても、URL は
/samples/:idになる - 検索エンジンのクローラーは基本的に「モーダルUI」を操作しない
→ でも 直リンクで詳細ページが表示できる ので問題になりにくい - 詳細ページ側で
generateMetadataなどを使えば、タイトル・description も個別に付けられる
つまり、
- UX:モーダルで快適
- SEO:詳細URLが正しく存在し、メタ情報も設定できる
が両立できます。
他に検討した「スクロール位置保持」方法
Parallel Routes + Intercepting Routes 以外には、以下の2つの方法を検討しました。
どちらも実装は可能で、それぞれの良さはあると思いますし、状況によっては以下2つの選択肢の方がより良いかもしれません。
方法1:Redux に scrollY を保存する
結論: 状態管理で “確実に” 復元できるが、実装がやや重く、画面ごとの管理が増える。
ざっくり手順
- 一覧ページでスクロールするたびに
scrollYを store に保存 - 戻ってきたタイミングで
window.scrollTo(0, savedY)
メリット
- 細かい制御ができる(一覧のフィルター条件なども一緒に保持しやすい)
- SPA内の遷移が増えても統一的に扱える
デメリット
- スクロールイベントの扱い(throttle/debounce)が必要
- 画面単位で “どのキーで保存するか” 管理が増える
- Next.js App Router では Client Component 前提が増えやすい
方法2:localStorage に保存する
結論: 簡単に導入できるが、多タブ・期限・ユーザー操作など考慮が増える。
メリット
- 実装が比較的シンプル
- リロードしても復元できる
デメリット
- 別タブで同じページを開くと競合しやすい
- “いつ消すか” の設計が必要(古い値で変な位置に飛ぶなど)
- SSR/Server Components と相性が良いとは言いづらい(window が必要)
Parallel Routes + Intercepting Routes で実装してみよう
サンプルページを作ってみましょう。
直リンク用の通常詳細ページも用意します。
一覧ページを /samples とし、/samples/[id] は一覧から遷移した場合はモーダル、直接URLに入力した場合は通常ページで表示します。
※ Parallel Routes + Intercepting Routes に関連した箇所を修正した後は、サーバーを起動し直さないと反映されない場合があります。
※ style などは適宜作り直してください。
Parallel Routes + Intercepting Routes に必要なファイル構成
app/
└─ samples/
├─ layout.tsx
├─ page.tsx
├─ [id]/
│ └─ page.tsx
└─ @modal/
├─ default.tsx
└─ (.)[id]/
└─ page.tsx
具体的な実装方法
1. /samples の layout(Parallel Routes の枠を作る)
結論: layout.tsx で children(一覧)と modal(モーダル枠)を同時に描画します。
import type { ReactNode } from "react";
export default function SamplesLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
{children}
{modal}
</>
);
}
2. @modal の default
default.tsx を置き、モーダルが無いときに空表示とする。
export default function ModalDefault() {
return null;
}
3. /samples 一覧ページ
通常通り Link で /samples/:id に遷移してOKです。
一覧ページから遷移したときは、Intercepting が “横取り” して自動でモーダル表示になります。
import Link from "next/link";
type Sample = { id: string; title: string };
const samples: Sample[] = Array.from({ length: 50 }).map((_, i) => ({
id: String(i + 1),
title: `Sample ${i + 1}`,
}));
export default function SamplesPage() {
return (
<main style={{ padding: 24 }}>
<h1>Samples</h1>
<p style={{ color: "#666" }}>
一覧をスクロールしてから任意の項目を開き、戻るとスクロール位置が保持されるはずです。
</p>
<ul style={{ display: "grid", gap: 12, padding: 0, listStyle: "none" }}>
{samples.map((s) => (
<li
key={s.id}
style={{
border: "1px solid #ddd",
borderRadius: 8,
padding: 12,
}}
>
<Link href={`/samples/${s.id}`} scroll={false}>
{s.title}
</Link>
</li>
))}
</ul>
</main>
);
}
4. 直リンク用:通常の詳細ページ
直リンクで開いたときは “通常ページ” として表示されます。
これは、SEOのために重要なポイントです。
type Props = {
params: Promise<{ id: string }>;
};
export default async function SampleDetailPage({ params }: Props) {
const { id } = await params;
return (
<main style={{ padding: 24 }}>
<h1>Sample Detail (Full Page)</h1>
<p>id: {id}</p>
<p style={{ color: "#666" }}>
これは直リンクやリロード時に表示される「通常の詳細ページ」です。
</p>
</main>
);
}
5. 一覧から遷移したときだけ:モーダルに詳細を表示(Intercepting Routes)
「同階層の samples を横取り」してモーダルが表示されます。
"use client";
import { useRouter } from "next/navigation";
import { use } from "react";
type Props = {
params: Promise<{ id: string }>;
};
export default function SampleDetailModal({ params }: Props) {
const router = useRouter();
const { id } = use(params);
return (
<div
role="dialog"
aria-modal="true"
onClick={() => router.back()}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
display: "grid",
placeItems: "center",
padding: 24,
color: "#000",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(720px, 100%)",
borderRadius: 12,
background: "white",
padding: 20,
maxHeight: "80vh",
overflow: "auto",
}}
>
<div
style={{ display: "flex", justifyContent: "space-between", gap: 12 }}
>
<h2 style={{ margin: 0 }}>Sample Detail (Modal)</h2>
<button onClick={() => router.back()} style={{ cursor: "pointer" }}>
Close
</button>
</div>
<p>id: {id}</p>
<p style={{ color: "#666" }}>
一覧ページの上にモーダルとして表示されています。背景の一覧が残るのでスクロール位置が維持されます。
</p>
</div>
</div>
);
}
これで Parallel Routes + Intercepting Routes ページの完成です。
わかりやすいようにモーダルをスクリーン全体に表示させていませんが、全体に表示させればユーザーにとっては通常詳細ページと見た目は全く変わらないはずです。
便利だと思うので、ぜひ試してみてください。
最後に
今回はスクロール位置を保持するために Parallel Routes + Intercepting Routes を使用しましたが、他のメリットも多数あると思います。
スクロール位置を保持する方法も、要件や現在の実装によって Parallel Routes + `Intercepting Routes 以外の選択が良い場合もあると思いますのでご注意ください。
また、株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。
※ シンシアにおける働き方の様子はこちら
弊社には年間100人以上の実務未経験の方に応募いただき、技術面接を実施しております。
この記事が少しでも学びになったという方は、ぜひ wantedly のストーリーもご覧いただけるととても嬉しいです!