フロントエンド

Next.js App Routerのページネーション実装|URL同期とLenisのスクロールずれ対策

コーポレートサイトのニュース一覧が200件を超え、スマホで見ると縦に90画面分という状態になっていました。そこで Next.js App Router 上にページネーションを実装したのですが、想像以上にハマりどころがありました。

特に苦労したのが、ページ送りボタンを押すと、スクロール位置が上に行ったり footer に飛んだりして安定しない問題です。原因はスムーススクロールライブラリの Lenis でしたが、そこにたどり着くまでに3回外し、たどり着いてからもう1回外しています。

この記事では、実装の設計判断とハマりどころ、そしてページネーションを入れるときに見落としがちな SEO 上の注意点をまとめます。

スポンサーリンク

この記事でわかること

  • Next.js App Router で、絞り込み状態とページ番号を URL に同期するページネーションの作り方
  • useSearchParams と history.pushState / replaceState の使い分け
  • Lenis 導入環境でページ送り後のスクロール位置がずれる原因と解決策
  • クライアント完結のページネーションが SEO 的にどう扱われるかと、SEO が必要な一覧での作り方の違い

前提・環境

  • Next.js 16(App Router)/ React 19 / TypeScript
  • Tailwind CSS v4
  • Lenis 1.3(ReactLenis root でサイト全体に適用)
  • 一覧データは静的な配列(ビルド時に確定)。記事カードのリンク先はすべて外部サイト

一覧は「タブ」「カテゴリ」「年」の3つで絞り込めるクライアントコンポーネントで、もともと useState で状態を持っていました。

なお、表示が重い問題については先に画像の最適化と loading="lazy" で対処しています。この記事で扱うのは「ページが長すぎる」という体験の問題です。

同じ Next.js App Router の実装例として、Route Handler でフォーム送信を受けて通知する問い合わせフォームの通知をLINEに飛ばす方法も書いています。

番号を並べるページャーにしなかった理由

ページャーは「前へ」「次へ」の矢印と 3 / 10 という位置表示だけのシンプルな形にしました。

  ←   3 / 10   →

一般的な 1 2 3 4 5 … 10 形式を採らなかったのは、件数が増え続ける一覧だからです。

1ページ24件で現在10ページ。年に40〜60件ずつ増えるので、1年で12ページ、2年で13〜15ページになります。番号を全部並べる形式は SP 幅ですぐに破綻し、1 2 3 … 14 15 のような省略表示を後から作り直すことになります。省略ロジックは「どこを省略するか」の分岐が地味に複雑で、バグの温床でもあります。

矢印+位置表示なら、ページ数が何桁になっても崩れません。代償は「特定ページへの直接ジャンプができない」ことですが、ニュース一覧でそれが必要な場面は少ないと判断しました。

実装1:ページ計算は純粋関数に切り出す

まずページ計算をコンポーネントから切り離し、ユニットテストできる純粋関数にします。

// lib/pagination.ts
export const DEFAULT_PER_PAGE = 24;

/** 総件数からページ数。0件でも1を返す("1 / 0" 表示を防ぐ) */
export function pageCount(total: number, perPage = DEFAULT_PER_PAGE): number {
  if (perPage <= 0) return 1;
  return Math.max(1, Math.ceil(Math.max(0, total) / perPage));
}

/** ページ番号を [1, 最終ページ] に収める */
export function clampPage(page: number, total: number, perPage = DEFAULT_PER_PAGE): number {
  if (!Number.isFinite(page)) return 1;
  return Math.min(Math.max(Math.trunc(page), 1), pageCount(total, perPage));
}

/** 指定ページに表示する範囲を切り出す */
export function pageSlice<T>(items: readonly T[], page: number, perPage = DEFAULT_PER_PAGE): readonly T[] {
  const current = clampPage(page, items.length, perPage);
  const start = (current - 1) * perPage;
  return items.slice(start, start + perPage);
}

ポイント:ページ番号は「読むときに」clampする

「すべて(10ページ)の8ページ目を見ている状態で、2ページ分しかないカテゴリに絞り込む」と、8ページ目は存在しなくなります。

これを useEffect や render 中の setState で補正しようとすると、余計な再描画やループの原因になります。そこで状態としては8を持ったまま、表示に使う直前で clamp することにしました。

const currentPage = clampPage(requestedPage, filtered.length); // 8 → 2
const visible = pageSlice(filtered, currentPage);

状態の補正が一切不要になり、コンポーネントがかなり素直になります。

テストでは「全ページを順に走査すると、全件がちょうど1回ずつ出る」ケースを入れておくと、オフセット計算のずれを確実に検出できます。

it("全ページで全件を1回ずつ網羅する", () => {
  const all = Array.from({ length: 227 }, (_, i) => i + 1);
  const seen: number[] = [];
  for (let p = 1; p <= pageCount(all.length); p++) seen.push(...pageSlice(all, p));
  expect(seen).toEqual(all);
});

実装2:URL を唯一の状態にする

useState だけで状態を持っていると、次の問題が起きます。

  • 5ページ目でリロードすると1ページ目に戻る
  • サイト内の別ページへ行って戻ると初期状態に戻る
  • 「このカテゴリの一覧」を人に URL で共有できない

そこで、タブ・カテゴリ・年・ページの4つをすべて URL のクエリに持たせ、useState は全廃しました。

/ja/news                                  ← 初期状態(URL は変わらない)
/ja/news?page=3
/ja/news?category=product
/ja/news?tab=notice&year=2025&page=2

パラメータ設計の3原則

1. デフォルト値は URL から省略する

初期状態の URL が /ja/news?tab=press&category=all&year=all&page=1 になるのは避けたいので、デフォルト値のパラメータは書き出しません。

2. URL に入れる値は ASCII にする

画面上のラベルが「〜2023年」でも、それをそのまま URL に入れると ?year=%E3%80%9C2023%E5%B9%B4 という読めない URL になります。?year=pre2024 のように ASCII のキーに変換します。

3. 不正な値は初期値にフォールバックする

URL は誰でも書き換えられます。?category=xxx や ?page=abc が来ても落ちないように、パースは寛容にしておきます。

// lib/list-url-state.ts
export interface ListState {
  readonly tab: "press" | "notice";
  readonly category: "all" | Category;
  readonly year: "all" | string; // "YYYY"
  readonly page: number;         // 上限の clamp は呼び出し側で
}

const DEFAULT: ListState = { tab: "press", category: "all", year: "all", page: 1 };

export function parseListState(params: { get(name: string): string | null }): ListState {
  const tab = params.get("tab") === "notice" ? "notice" : "press";

  const rawCategory = params.get("category");
  const category = rawCategory && CATEGORY_KEYS.has(rawCategory) ? (rawCategory as Category) : "all";

  const rawYear = params.get("year");
  const year = rawYear && /^\d{4}$/.test(rawYear) ? rawYear : "all";

  const rawPage = Number.parseInt(params.get("page") ?? "", 10);
  const page = Number.isFinite(rawPage) && rawPage >= 1 ? rawPage : 1;

  return { tab, category, year, page };
}

export function serializeListState(state: ListState): string {
  const q = new URLSearchParams();
  if (state.tab !== DEFAULT.tab) q.set("tab", state.tab);
  if (state.category !== DEFAULT.category) q.set("category", state.category);
  if (state.year !== DEFAULT.year) q.set("year", state.year);
  if (state.page > 1) q.set("page", String(state.page));
  const s = q.toString();
  return s ? `?${s}` : "";
}

page の上限だけはパーサーでは判定しません。上限は「絞り込み後の件数」がわからないと決まらないので、実装1の clampPage に任せます。

読み取りは useSearchParams、書き込みは History API

"use client";
import { useMemo } from "react";
import { useSearchParams } from "next/navigation";

export function NewsList({ items }: { items: readonly NewsItem[] }) {
  const searchParams = useSearchParams();
  const state = useMemo(() => parseListState(searchParams), [searchParams]);

  // ...絞り込み...
  const currentPage = clampPage(state.page, filtered.length);

  const update = (patch: Partial<ListState>, mode: "push" | "replace") => {
    const next = { ...state, page: currentPage, ...patch };
    const url = `${location.pathname}${serializeListState(next)}${location.hash}`;
    if (mode === "push") history.pushState(null, "", url);
    else history.replaceState(null, "", url);
  };

  const goToPage = (p: number) => update({ page: p }, "push");
  const onCategoryChange = (c: Category | "all") => update({ category: c, page: 1 }, "replace");
  // ...
}

Next.js 14.1 以降、ネイティブの history.pushState / replaceState は Next.js のルーターと統合されており、useSearchParams がその変更に追従します。そのため URL を書き換えるだけで再描画され、ローカルの state を別途更新する必要はありません。

router.push を使わないのは、App Router の router.push は RSC ペイロードの再取得が走るからです。サーバーに問い合わせる必要のない、クライアント完結の絞り込みには重すぎます。

pushState と replaceState の使い分け

操作 方式 ブラウザバックの挙動
ページ送り pushState 1つ前のページに戻る
タブ・カテゴリ・年の変更 replaceState 一覧ページ自体を離れる

「3ページ目まで読んで戻る」は自然な操作なので、ページ送りは履歴に積みます。一方、フィルタを何度か試したあとに「戻る」を5回押さないと元のページに戻れない、というのは不便なので、フィルタ変更は履歴に積みません。

フィルタを変えたらページは1に戻します。元のページ番号が新しい絞り込み結果に存在するとは限らないからです。

副次的な効果

useState を全廃したことで、ヘッダーの「ニュース」を再クリックすると一覧が初期状態に戻るようになりました。以前は同じルートへの遷移でコンポーネントが保持され、5ページ目のまま残っていました。状態の出所を URL に一本化すると、こういう細かい不整合も自然に消えます。

実装3:useSearchParams には Suspense 境界が必要

静的に生成されるページのクライアントコンポーネントで useSearchParams を使うと、Suspense 境界が必要になります。ビルド時にはクエリパラメータがわからないためです。

// app/[locale]/news/page.tsx(Server Component)
import { Suspense } from "react";

export default async function NewsPage() {
  return (
    <>
      <FeaturedArticles />  {/* こちらは SSR のまま */}
      <Suspense fallback={null}>
        <NewsList items={NEWS_ITEMS} />
      </Suspense>
    </>
  );
}

ページ全体が “use client” だった場合

もう一方の一覧ページは、page.tsx 自体の先頭に "use client" が付いていました。この場合、ページ自身を Suspense で包むことはできません。一覧部分を子コンポーネントに切り出し、page.tsx をサーバーコンポーネントに戻す構造変更が必要になります。

// Before: page.tsx 全体が "use client"、useParams で locale を取得
// After:
export default async function BlogPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  return (
    <>
      <h1>BLOG</h1>  {/* 見出しは SSR */}
      <Suspense fallback={null}>
        <BlogList locale={locale} />  {/* "use client" はこちらへ */}
      </Suspense>
    </>
  );
}

トレードオフ

Suspense の内側は、初期 HTML に含まれずクライアント側で描画されます。一覧の表示が一瞬遅れます。この影響は後述の SEO の節で詳しく触れます。

スポンサーリンク

ハマりどころ:ページ送り後にスクロール位置が footer へ飛ぶ

ここからが本題です。

ページ送りボタンは一覧の下部にあります。押した後にスクロール位置を何もしないと、新しいページの最下部にいる状態になってしまいます。そこで「ページが変わったら一覧の先頭に戻す」処理を入れたのですが、これが安定しませんでした。正常な位置に戻るときもあれば、footer 付近に着地するときもある。

試行1:クリックハンドラで scrollIntoView → 失敗

URL 化する前のコードなので、ページ番号はまだ useState で持っています。

const goToPage = (p: number) => {
  setPage(p);
  listTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};

setPage は非同期なので、scrollIntoView が呼ばれた時点では古いページの DOM が残っています。スムーススクロールが始まった直後に再描画が走り、コンテンツが入れ替わります。特に最終ページ(11件)では文書が一気に短くなり、ブラウザがスクロール位置を切り詰めるため、アニメーションが中断されて footer 付近に着地する──と考えました。

→ useEffect で DOM 更新後にスクロールするよう変更。改善せず。

試行2:smooth をやめて即時移動 → 失敗

サムネイルは loading="lazy" かつ width / height 未指定なので、読み込み前は高さ0です。スムーススクロール中に画像が次々読み込まれて高さが変わり、Chrome のスクロールアンカリングと競合しているのでは、と疑いました。

→ behavior: "instant" に変更。改善せず。

試行3:スクロール先をタブに変更 → 失敗

スクロールアンカリングの基準要素が「高さ0の画像の下にあるタイトル」になっているのでは、と考え、画像の影響を受けないタブを狙うことにしました。

→ 改善せず。むしろ「タブの上まで行かない」ことが多くなりました。

真因:Lenis がスクロール位置を引き戻していた

ここでようやく、サイト全体に Lenis(スムーススクロールライブラリ)が入っていることに気づきました。

// layout/SmoothScroll.tsx
<ReactLenis root options={{ duration: 1.1, smoothWheel: true }}>
  {children}
</ReactLenis>

Lenis は自分が管理しているスクロール位置を持ち、毎フレームそこへ向かって補間します。scrollIntoView や window.scrollTo でブラウザのスクロール位置だけを変えても、Lenis の記憶は「前のページの最下部」のまま。次のフレームで引き戻されていました。フレームのタイミング次第で結果が変わるので、「安定しない」ように見えていたわけです。

さらにもう一段:lenis.scrollTo に要素を渡してもずれる

では Lenis の API を使えばいいだろう、と lenis.scrollTo(element) にしましたが、まだずれました。

Lenis のソースを読むと、要素を渡したときの目標位置はこう計算されています。

// lenis の scrollTo 内部(抜粋)
const rect = node.getBoundingClientRect();
target = rect.top + this.animatedScroll - scrollMargin - scrollPadding;

this.animatedScroll は Lenis 内部のスクロール位置です。再描画で文書が短くなった直後、ブラウザは既にスクロール位置を切り詰めていますが、Lenis がそれを知るのは次のネイティブ scroll イベントが届いてから。useLayoutEffect の中ではまだ古い値のままです。

結果として目標位置が実際より大きく計算され、文書の末尾に丸められて footer に着地していました。

解決:絶対座標を自分で計算して渡す

"use client";
import { useLayoutEffect, useRef } from "react";
import { useLenis } from "lenis/react";

function headerOffsetPx(): number {
  const root = document.documentElement;
  const rem = parseFloat(getComputedStyle(root).getPropertyValue("--header-height")) || 5;
  const base = parseFloat(getComputedStyle(root).fontSize) || 16;
  return rem * base + 8; // 固定ヘッダーの高さ + 少しの余白
}

export function NewsList(/* ... */) {
  const topRef = useRef<HTMLDivElement>(null);
  const pendingScrollRef = useRef(false);
  const lenis = useLenis();

  useLayoutEffect(() => {
    if (!pendingScrollRef.current) return;
    pendingScrollRef.current = false;
    const el = topRef.current;
    if (!el) return;

    const go = () => {
      // Lenis 内部の値ではなく、ブラウザの実際の scrollY を基準にする
      const y = Math.max(0, window.scrollY + el.getBoundingClientRect().top - headerOffsetPx());
      if (lenis) lenis.scrollTo(y, { immediate: true, force: true });
      else window.scrollTo({ top: y, behavior: "instant" });
    };

    go();
    // Lenis の内部同期が遅れて届いても引き戻されないよう、数フレーム後にも再適用
    const timers = [setTimeout(go, 0), setTimeout(go, 60)];
    return () => timers.forEach(clearTimeout);
  }, [currentPage, lenis]);

  const goToPage = (next: number) => {
    const target = clampPage(next, filtered.length);
    if (target === currentPage) return; // フラグが残って後で誤発火するのを防ぐ
    pendingScrollRef.current = true;
    update({ page: target }, "push");
  };

  return <div ref={topRef}>{/* タブ・フィルタ・一覧・ページャー */}</div>;
}

要点は4つです。

  1. lenis.scrollTo を使う:ネイティブ API では Lenis に引き戻される
  2. 要素ではなく数値(絶対座標)を渡す:要素を渡すと Lenis 内部の古い位置が基準になる
  3. immediate: true:遅延読み込みの画像で高さが変わり続ける区間をアニメーションで横切らない
  4. useLayoutEffect:DOM 更新後かつ描画前に実行し、ずれた位置が一瞬でも見えないようにする

数フレーム後の再適用は保険です。ハッシュ付きリンクでの遷移など、既存のアンカースクロール処理でも同じ手法が使われていたので、それに揃えました。

goToPage で「ページが変わらないなら何もしない」ガードを入れているのは、フラグだけが立ったまま残り、後の無関係なページ変更で誤発火するのを防ぐためです。

教訓

Lenis(や Locomotive Scroll などのスムーススクロールライブラリ)が入っているプロジェクトで、スクロール位置が不安定になったら、まずライブラリを疑う。

私は React の描画順、レイアウトシフト、スクロールアンカリングと3つの仮説を積み上げましたが、いずれも副次的でした。package.json を最初に見ていれば、1回目で当たっていたはずです。

SEO の観点:このページネーションはクロールされない

ここは誤解されやすいので、明確にしておきます。

今回の実装では、2ページ目以降の中身は検索エンジンにほぼ見えません。 理由は2つあります。

  1. ページ送りが <button> でリンクではない
    Google は <a href> をたどって次のページを見つけます。onClick で URL を書き換えるボタンは、クローラーにとってリンクではありません
  2. 一覧が Suspense の内側でクライアント描画
    初期 HTML に一覧が含まれていないため、?page=3 の HTML も ?page=1 の HTML も、一覧部分はどちらも空です

それでも今回は問題ないと判断した理由

  • 記事カードのリンク先がすべて外部サイト(プレスリリース配信サイトや別ドメインのブログ)で、検索エンジンにはリンク先そのものがインデックスされていれば十分。こちらの一覧経由で発見してもらう必要がない
  • sitemap に登録しているのも一覧のトップ URL のみ

つまりこのページネーションは「人が読むための UI」であって、「クローラーに記事を発見させる仕組み」ではないと割り切っています。

SEO が必要な一覧(ブログのアーカイブや EC の商品一覧)の場合

リンク先が自サイトの記事や商品で、一覧経由でクロールさせたい場合は、作り方が変わります。Google のページネーションに関するガイドラインの要点は次のとおりです。

  • 各ページに固有の URL を持たせる(?page=2 のようなクエリで可。ただし #page=2 のようなフラグメントは Google に無視されるので不可)
  • ページ間を <a href> でリンクする。ボタン+onClick では辿れない
  • canonical を1ページ目に向けない。各ページが自分自身を canonical にする
  • <link rel="next"> / <link rel="prev"> は Google はもう使っていない(他の検索エンジンが使う可能性はある)

Next.js で実装するなら、page.tsx 側で searchParams を受け取ってサーバーで該当ページを描画し、ページャーは <Link href="?page=3"> にする形になります。この場合、ページは動的レンダリングになるので、キャッシュ戦略もあわせて検討が必要です。

「URL にページ番号が入っているから SEO 対応済み」ではない、という点は押さえておきたいところです。

地味だけど効く Tips:Tailwind v4 ではボタンのカーソルが矢印になる

実装中、ページ送りボタンにカーソルを当てても指マークにならないことに気づきました。

これは Tailwind CSS v4 の仕様変更で、button のデフォルトカーソルが pointer から default に変わっています(ブラウザ標準の挙動に合わせたため)。v3 からアップグレードしたプロジェクトでは、既存のボタンもすべて矢印カーソルになっているはずです。

個別に cursor-pointer を付けるか、v3 の挙動に戻したければベーススタイルで一括指定します。

@layer base {
  button:not(:disabled),
  [role="button"]:not(:disabled) {
    cursor: pointer;
  }
}

無効化したボタンは disabled:pointer-events-none を付けておくと、ホバーも反応しなくなり、「押せないボタンに指マークが出る」問題も同時に防げます。

よくある質問

Q. 記事が増えると、ページ番号付きの URL の中身はずれますか?

ずれます。新着が1件入ると全体が1つ後ろにずれるので、?page=2 以降の中身は日々少しずつ変わります(オフセット方式の宿命です)。

ただ、ページ番号付きの URL を人に共有する場面はまずありません。page を URL に入れる目的は「リロードや戻る操作での自分自身の状態復元」で、それは数秒〜数分の話なので実用上は問題になりません。共有向けの URL は、時間が経っても意味が変わらない ?category=... のほうです。

Q. router.push でクエリを更新してはいけないのですか?

動作はしますが、App Router の router.push はサーバーへの RSC ペイロード再取得が走ります。データが全部クライアントにある絞り込みなら、history.pushState / replaceState を直接使うほうが軽量です。Next.js 14.1 以降はこれらがルーターと統合されているので、useSearchParams も正しく追従します。

Q. 「もっと見る」ボタン方式ではダメでしたか?

「もっと見る」は初期表示を短くできますが、押し続ければ結局ページは長くなります。今回の課題は「ページが長すぎる」ことそのものだったので、常にページ長が一定に保たれるページネーションを選びました。

Q. Lenis を使っていない場合も、この書き方で問題ありませんか?

問題ありません。useLenis() が undefined を返す環境では window.scrollTo にフォールバックするようにしています。ただし Lenis なしであれば、クリックハンドラではなく useLayoutEffect でスクロールする、という点だけ守れば scrollIntoView でも十分動くはずです。

まとめ

  • ページ番号は 状態としては持ったまま、読むときに clamp すると、補正処理が不要になる
  • URL を唯一の状態源にすると、リロード・戻る・共有がすべて自然に動き、状態の不整合も消える
  • 読み取りは useSearchParams、書き込みは history.pushState / replaceState。ページ送りは push、フィルタは replace
  • 静的ページで useSearchParams を使うなら Suspense 境界が必要。ページ全体が "use client" なら切り出しが要る
  • Lenis 環境では lenis.scrollTo に絶対座標を渡す。ネイティブ API も要素渡しもずれる
  • クライアント完結のページネーションはクロールされない。SEO が必要な一覧なら <a href> と SSR で作る

スクロールの件は、ライブラリの存在に気づけば一瞬で解ける問題でした。同じところでハマっている方の時間を少しでも節約できれば幸いです。

参考

スポンサーリンク