24
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Next.js] Parallel Routes + Intercepting Routes でページ遷移時にスクロール位置を保持する

24
Posted at

はじめに

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は詳細ページという “良いとこ取り” ができます。

こちらもシンプルに説明していますが、自分は 公式ドキュメントこちらの記事 を参考にさせていただきましたので、詳しく理解したい方はぜひお読みください。

モーダル形式にするとなぜスクロール位置が保持できるのか

結論: 一覧ページ(背景)が 描画されたまま残り、詳細はその上に “重ねる” だけなので、ブラウザのスクロール位置が変わらないからです。

通常のページ遷移だと、

  1. /samples のコンポーネントがアンマウント
  2. /samples/:id がマウント
  3. 戻ると再レンダリングやデータ再取得が発生しやすい
  4. スクロールが先頭に戻りがち

という流れになります。

一方、モーダル(オーバーレイ)だと、

  • 背景(一覧)が 残る
  • 「詳細」は別スロットで 追加描画されるだけ
  • 戻ると “モーダルが消えるだけ” に近い

ので、一覧のスクロール位置が自然に維持されます。

自分が関わっていたページは、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(モーダル枠)を同時に描画します。

app/samples/layout.tsx
import type { ReactNode } from "react";

export default function SamplesLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal: ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}

2. @modal の default

default.tsx を置き、モーダルが無いときに空表示とする。

app/samples/@modal/default.tsx
export default function ModalDefault() {
  return null;
}

3. /samples 一覧ページ

通常通り Link で /samples/:id に遷移してOKです。
一覧ページから遷移したときは、Intercepting が “横取り” して自動でモーダル表示になります。

app/samples/page.tsx
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のために重要なポイントです。

app/samples/[id]/page.tsx
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 を横取り」してモーダルが表示されます。

app/samples/@modal/(.)[id]/page.tsx
"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 のストーリーもご覧いただけるととても嬉しいです!

24
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?