ページネーション実装で番号送りとカーソルを選ぶ基準とコード
ページ送り型の実装で迷うオフセットとカーソルの選び方を、URL設計・JSON API・落とし穴つきで解説。Claude Codeへの依頼文と動くコードも掲載。
「記事一覧、ページ分けといて」。
Claude Codeにそう頼んだら、5分で「前へ / 1 2 3 / 次へ」が出てきました。動くんです。デモとしては完璧。でも本番に乗せた翌週、お客さんから「3ページ目を共有したのに、相手が開いたら1ページ目になる」と連絡が来ました。
URLにページ番号が残っていなかった。それだけの話です。でも、ページネーションが厄介なのはまさにそこで、壊れるのはボタンの見た目じゃなくて、その裏側の設計なんですよね。
僕が記事一覧と管理画面で何度も踏んだ地雷は、だいたい決まっています。page=0が来る。記事を消したら最終ページが消えて404になる。検索したまま次ページを押すと条件が飛ぶ。そして一番厄介なのが、最初に「番号送り」と「カーソル」のどっちで作るかを決めずに着手したことでした。
この記事は、その「どっちで作るか」と「番号送りをどう実装するか」に絞ります。下に向かって自動で継ぎ足していく方は 無限スクロール実装 に分けたので、ここでは扱いません。ページを「送る」型、つまりユーザーが番号やカーソルで明示的にめくるUIの話だけをします。
この記事の要点
- ページ送り型は オフセット(番号指定) と カーソル(基準点指定) の2種類。先にどっちかを決めてからClaude Codeに頼むと手戻りが激減する。
- 共有・SEO・任意ページへのジャンプが要るなら オフセット。増減し続けるログやフィードで重複・取りこぼしを避けたいなら カーソル。
- オフセット型の正本は React stateではなくURL。
pageとqをURLに置き、page=1はURLから消すと共有リンクが安定する。 - 異常値(
page=0page=abcpage=9999)は必ず来る。サーバー側で1以上の整数に丸め、最終ページを超えたら最後のページにするのが事故防止の肝。 - JSON APIなら
pageSizeに上限を、レスポンスにtotalPagesとhasNextPageを。これがないとフロントが毎回つらい。
まず「番号送り」か「カーソル」かを決める
着手前に1つだけ決めます。これを飛ばすと、あとで作り直しになります。
オフセット方式は「3ページ目を10件ずつ」のように番号で位置を指定します。/articles?page=3&q=react のようなURLをそのまま共有でき、5ページ目へ直接ジャンプできる。記事アーカイブ、検索結果、商品一覧、管理画面のテーブルはこれが向いています。
カーソル方式は「このIDの次から10件」のように基準点を渡します。新しい行が上に積まれ続ける通知・監査ログ・チャット履歴・時系列フィードで、重複や取りこぼしを避けやすい。一方で「いきなり7ページ目」みたいな任意ジャンプは苦手です。
迷ったときの僕の判断はこうです。URLを共有したいか、新しい行が増え続けるか。 共有したいならオフセット、増え続けるならカーソル。両方なら主役をオフセットにして、内部の重い一覧だけカーソルにします。
| 方式 | 向いている画面 | 弱点 |
|---|---|---|
| オフセット | 記事一覧、検索結果、商品一覧、管理テーブル | 件数が変わると最終ページがずれる |
| カーソル | 通知、監査ログ、チャット履歴、時系列フィード | 任意ページへ直接ジャンプしにくい |
ちなみに「下スクロールで勝手に継ぎ足す」無限スクロールは、ページを”送らない”のでこの表の外です。戻る操作・フッター到達・SEOの難しさがあって設計思想が別なので、必要なら 無限スクロール実装 を読んでから選んでください。
Claude Codeはコードベースを読んでファイルを編集し、コマンドまで実行するエージェント型のツールです。だからこそ「どっちの方式か」「どのURLを正とするか」を先に渡すほど、既存構成に素直に乗ります。現在の概要は Claude Code公式Overview が一次情報です。
Claude Codeへの依頼文(完了条件まで書く)
ページネーションはUI・URL・データ取得・アクセシビリティをまたぎます。だから「Reactで作って」だと必ず抜けが出る。僕は最初のプロンプトで完了条件を固定するようにしています。
Next.js App RouterとReactで記事一覧のページネーション(オフセット方式)を実装してください。
要件:
- URLの page と q を正とし、page=1 はURLから省略する
- Next.js 15以降の searchParams Promise に対応する
- 1ページ10件、page=0 や数字以外は1へ丸める
- 最終ページを超えた場合は最後のページを表示する
- 現在ページに aria-current="page" を付ける
- 前へ/次へは無効時にリンクではなくspanにする
- 既存の heroImage、frontmatter、内部リンクを壊さない
- 実装後に境界値(page=0, abc, 9999, 検索あり/なし, 最終ページ)のテスト観点を箇条書きで出す
ポイントは最後の2行です。「壊すな」と「テスト観点を出せ」を入れておくと、Claude Codeが自分でチェックリストを返してくるので、レビューがそのまま回ります。
Next.jsの今のApp Routerでは、page.tsxのsearchParamsは Promiseとしてawaitして読む のが新しい書き方です。公式の page.jsリファレンス でもそうなっています。クライアント側でURLを読むなら useSearchParams ですが、これは 読み取り専用 のURLSearchParamsを返すので、直接setしようとして詰まる人が多いです(僕も最初やりました)。
URLを正本にしてページを切り出す
まずサーバーコンポーネントだけで動く一覧を作ります。qとpageをURLから読み、ページ番号を安全に丸め、検索条件を保ったままPaginationへ渡す。サンプルは配列ですが、実務では同じ形をそのままDBクエリに置き換えられます。
import { Pagination } from "@/components/Pagination";
const PAGE_SIZE = 10;
// 実務ではDBの結果に差し替える。ここでは87件のダミーで挙動を確認する
const articles = Array.from({ length: 87 }, (_, index) => ({
id: `article-${index + 1}`,
title: `Claude Code pagination note ${index + 1}`,
createdAt: new Date(Date.UTC(2026, 0, index + 1)).toISOString(),
}));
type SearchParams = Promise<{
page?: string;
q?: string;
}>;
// 異常値(0, abc, 負数)はここで1へ丸める。URLは直接編集される前提で守る
function readPage(value: string | undefined) {
const page = Number(value ?? "1");
return Number.isInteger(page) && page > 0 ? page : 1;
}
export default async function ArticlesPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const params = await searchParams; // Next.js 15以降はPromise
const query = params.q?.trim() ?? "";
const requestedPage = readPage(params.page);
const filtered = query
? articles.filter((article) =>
article.title.toLowerCase().includes(query.toLowerCase()),
)
: articles;
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(requestedPage, totalPages); // 最終ページ超えを丸める
const start = (currentPage - 1) * PAGE_SIZE;
const visibleArticles = filtered.slice(start, start + PAGE_SIZE);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-3xl font-bold">Articles</h1>
<form action="/articles" className="mt-6 flex gap-2">
<input
type="search"
name="q"
defaultValue={query}
placeholder="Search articles"
className="min-w-0 flex-1 rounded border px-3 py-2"
/>
<button className="rounded bg-black px-4 py-2 text-white">Search</button>
</form>
<p className="mt-4 text-sm text-gray-600">
{filtered.length} articles, page {currentPage} of {totalPages}
</p>
<ul className="mt-6 divide-y">
{visibleArticles.map((article) => (
<li key={article.id} className="py-4">
<h2 className="font-semibold">{article.title}</h2>
<time className="text-sm text-gray-500" dateTime={article.createdAt}>
{new Intl.DateTimeFormat("en").format(new Date(article.createdAt))}
</time>
</li>
))}
</ul>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
basePath="/articles"
query={{ q: query || undefined }}
/>
</main>
);
}
肝はひとつ、URLを状態の正本にする ことです。React stateだけでページ番号を持つと、戻るボタン・共有リンク・再読み込み・クローラーで状態が消えます。冒頭で僕がやらかした「共有したら1ページ目」は、まさにこれが原因でした。URLSearchParamsはクエリ文字列を扱う標準APIで、仕様は MDN URLSearchParams が基準です。
JSON APIにするなら上限とメタ情報を必ず
同じページングロジックをJSON APIで公開するなら、pageとpageSizeは 上限つき で読みます。Claude Codeが何も言わないと出しがちな失敗が、pageSize=100000をそのまま通すコード。DBにもレスポンスにも一発で負荷がかかります。
import type { NextRequest } from "next/server";
const MAX_PAGE_SIZE = 50; // ここが無いと pageSize=100000 が通ってしまう
const articles = Array.from({ length: 87 }, (_, index) => ({
id: `article-${index + 1}`,
title: `Claude Code pagination note ${index + 1}`,
createdAt: new Date(Date.UTC(2026, 0, index + 1)).toISOString(),
}));
function readPositiveInt(value: string | null, fallback: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
export async function GET(request: NextRequest) {
const page = readPositiveInt(request.nextUrl.searchParams.get("page"), 1);
const requestedSize = readPositiveInt(
request.nextUrl.searchParams.get("pageSize"),
10,
);
const pageSize = Math.min(requestedSize, MAX_PAGE_SIZE); // 上限で頭打ち
const totalItems = articles.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return Response.json({
items: articles.slice(start, start + pageSize),
meta: {
page: safePage,
pageSize,
totalItems,
totalPages,
hasPreviousPage: safePage > 1,
hasNextPage: safePage < totalPages, // フロントの「次へ」判定はこれ1個で済む
},
});
}
これはapp/api/articles/route.tsに置けます。Route Handlerをapp配下のroute.tsで定義するのは route handler公式ドキュメント のとおりです。実務では配列の代わりにDBでcountとfindManyを実行しますが、pageSizeの上限・safePage・metaの形はそのまま流用できます。API全体の設計を一段上から整えたいときは REST API設計ガイド にページングと冪等性をまとめてあります。
カーソル方式は「最後に読んだ位置」を渡す
オフセットの弱点は、件数が動くとズレること。ログのように1秒で新しい行が積まれる一覧だと、OFFSET 20 の最中に行が増えて、同じ行が2ページ目にも出る、なんてことが起きます。
そこでカーソル方式です。OFFSETの代わりに「最後に見たIDの次から」を条件にする。SQLにすると違いが一目でわかります。これはPostgreSQLでそのまま実行できます。
-- オフセット方式: ページが深いほど、捨てる行が増えて遅くなる
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 200; -- 200行を読み飛ばしてから10行返す
-- カーソル方式: 直前ページの最後の (created_at, id) を基準点として渡す
-- 例: 最後の行が created_at='2026-03-01T00:00:00Z' / id='article-77' だった場合
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-03-01T00:00:00Z', 'article-77')
ORDER BY created_at DESC, id DESC
LIMIT 10; -- 読み飛ばしゼロ。(created_at, id) の複合インデックスで安定して速い
カーソル方式のコツは2つ。並び順を 一意になる組 (ここではcreated_atが同値でもidで確定する)にすること、そして(created_at, id)に複合インデックスを張ることです。これで深いページでも速度が落ちません。hasNextPageは「LIMIT 10に対して11件目を取りにいって、あれば次がある」と判定すると正確です。
APIのレスポンスは、page番号の代わりに次のカーソルを返します。
return Response.json({
items,
meta: {
// 取得した最後の行から次のカーソルを組み立てて返す
nextCursor: items.length === pageSize ? encodeCursor(lastRow) : null,
hasNextPage: items.length === pageSize,
},
});
encodeCursorはcreated_atとidをBase64などに詰めるだけの関数でかまいません。クライアントはそれをそのまま次のリクエストに乗せます。番号を持たないぶん「7ページ目へジャンプ」はできませんが、増え続ける一覧では取りこぼしと重複が消えます。
アクセシブルなページUIにする
見た目はCSSで自由でいいですが、意味は固定します。navにaria-label、現在ページにaria-current="page"、無効な前後リンクは「押せるリンク」ではなくspanに。MDNの aria-current解説 でも、ページネーションの現在ページにaria-current="page"を付ける例が示されています。読み上げやキーボード操作を作り込むなら アクセシビリティ実装 もどうぞ。
import Link from "next/link";
type QueryValue = string | number | undefined;
type PaginationProps = {
currentPage: number;
totalPages: number;
basePath: string;
query?: Record<string, QueryValue>;
previousLabel?: string;
nextLabel?: string;
};
function normalizePage(page: number, totalPages: number) {
return Math.min(Math.max(1, page), Math.max(1, totalPages));
}
// 先頭・末尾・現在ページ前後だけ出し、間は「...」で省略する
function visiblePages(currentPage: number, totalPages: number) {
const pages = new Set([1, totalPages, currentPage - 1, currentPage, currentPage + 1]);
return [...pages]
.filter((page) => page >= 1 && page <= totalPages)
.sort((a, b) => a - b);
}
// 既存クエリ(q など)を保ったまま page だけ差し替える。page=1 は消す
function hrefForPage(
basePath: string,
query: Record<string, QueryValue>,
page: number,
) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== "") params.set(key, String(value));
}
if (page === 1) {
params.delete("page");
} else {
params.set("page", String(page));
}
const queryString = params.toString();
return queryString ? `${basePath}?${queryString}` : basePath;
}
export function Pagination({
currentPage,
totalPages,
basePath,
query = {},
previousLabel = "Previous",
nextLabel = "Next",
}: PaginationProps) {
if (totalPages <= 1) return null;
const safePage = normalizePage(currentPage, totalPages);
const pages = visiblePages(safePage, totalPages);
return (
<nav className="mt-8" aria-label="Pagination">
<ol className="flex flex-wrap items-center gap-2">
<li>
{safePage === 1 ? (
<span aria-disabled="true" className="rounded border px-3 py-2 opacity-50">
{previousLabel}
</span>
) : (
<Link
className="rounded border px-3 py-2 hover:bg-gray-50"
href={hrefForPage(basePath, query, safePage - 1)}
>
{previousLabel}
</Link>
)}
</li>
{pages.map((page, index) => {
const previous = pages[index - 1];
const needsGap = previous !== undefined && page - previous > 1;
return (
<li key={page} className="flex items-center gap-2">
{needsGap ? <span aria-hidden="true">...</span> : null}
<Link
aria-current={page === safePage ? "page" : undefined}
className={
page === safePage
? "rounded border bg-black px-3 py-2 text-white"
: "rounded border px-3 py-2 hover:bg-gray-50"
}
href={hrefForPage(basePath, query, page)}
>
{page}
</Link>
</li>
);
})}
<li>
{safePage === totalPages ? (
<span aria-disabled="true" className="rounded border px-3 py-2 opacity-50">
{nextLabel}
</span>
) : (
<Link
className="rounded border px-3 py-2 hover:bg-gray-50"
href={hrefForPage(basePath, query, safePage + 1)}
>
{nextLabel}
</Link>
)}
</li>
</ol>
</nav>
);
}
クライアント側でボタン押下だけ差し替えたいなら、useSearchParamsを読み取りに使い、変更時は新しいURLSearchParamsを作ってrouter.pushします。戻り値は読み取り専用なので、直接setしないのがコツです(さっき書いたとおり、ここでハマる人が多い)。
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
export function usePageQuery() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams(); // 読み取り専用
const [isPending, startTransition] = useTransition();
function goToPage(page: number) {
// toString() でコピーしてから書き換える。元のオブジェクトはいじらない
const params = new URLSearchParams(searchParams.toString());
if (page <= 1) {
params.delete("page");
} else {
params.set("page", String(page));
}
const queryString = params.toString();
startTransition(() => {
router.push(queryString ? `${pathname}?${queryString}` : pathname);
});
}
return { goToPage, isPending };
}
どこで使うか(実務のユースケース)
1. ブログやドキュメントのアーカイブ。 記事が増えても初期表示を軽くでき、page=4のURLを検索結果やSNSから直接開ける。AdSenseやSEOを考えるなら、各ページに 通常のリンクで到達できる ことが大事です。ここは無限スクロールにせず番号送りにする理由そのものです。
2. ECやSaaSの検索結果。 検索語・カテゴリ・価格帯・並び順をURLに残せば、同じ結果をそのまま共有できます。Claude Codeには「フィルター変更時はpageを1へ戻す」と必ず明示します。これを書かないと、5ページ目で絞り込みを変えて「0件に見える」事故が起きる。僕は2回やりました。
3. 管理画面のテーブル。 請求一覧・ユーザー一覧・問い合わせ一覧では、ページサイズの上限、権限フィルター、CSV出力との整合が効いてきます。表示ページとエクスポート件数がズレると運用事故なので、metaを共通化しておく。
4. 学習コンテンツの一覧。 読者が数日に分けて戻ってくるので、URLで続きの位置を復元できると離脱が減ります。一覧の途中から戻れる設計は、CTAを Claude Code研修・導入相談 や 無料チートシート につなぐときにも効きます。
よくある落とし穴
ページ番号を信じすぎる。 page=-1 page=abc page=9999は必ず来ます。UIから出ない値でも、URLを直接書き換えれば送れる。サーバー側で1以上の整数に丸め、最終ページ超えは最後のページにする。
検索条件をページリンクから落とす。 q=react&page=2で進んだのに次リンクが?page=3だけになると条件が消えます。リンク生成関数は必ず既存クエリを受け取り、pageだけ差し替える。
現在ページを色だけで示す。 視覚的にわかっても支援技術には伝わりません。aria-current="page"を1つだけ付ける、navにラベルを付ける、無効リンクを本物のリンクにしない。
件数クエリを毎回重くする。 大規模DBでCOUNT(*)が高いなら、条件に合わせたインデックス、概算件数、上限つき表示を検討します。深いページが遅いなら、そこだけカーソル方式に切り替えるのが効きます。
戻るボタンの体験。 クライアントstateだけでページを変えると履歴に残らないことがあります。pushStateはセッション履歴にエントリを足すAPIで、低レベル挙動は MDN History pushState にありますが、Next.jsでは通常Linkかrouter.pushを使い、履歴に残すか置換するかを明示します。
よくある質問
Q. オフセットとカーソル、結局どっちを選べばいい? URLを共有したい・任意のページへ飛びたいならオフセット。新しい行が増え続けて重複や取りこぼしを避けたいならカーソルです。記事一覧や検索結果はオフセット、通知やログはカーソルが基本線になります。
Q. page=1をURLから消すのはなぜ?
共有リンクが短く安定し、/articlesと/articles?page=1が同じ内容で二重化するのを防げます。SEO的にも正規URLが1つに寄るので扱いやすくなります。
Q. カーソル方式で「3ページ目へジャンプ」はできない? 番号を持たないので、原則できません。任意ジャンプが要件なら、主役をオフセットにするか、ジャンプは諦めて「前へ/次へ」だけにするか、どちらかを最初に決めておくのが安全です。
Q. 無限スクロールとの違いは? 無限スクロールは下に近づくと自動で継ぎ足す方式で、ユーザーはページを「送り」ません。戻る操作・フッター到達・SEOの難しさがあるので、設計思想ごと別物です。比較と実装は 無限スクロール実装 にまとめています。
Q. Claude Codeに頼むとき一番効く一文は?
「実装後に境界値(page=0, abc, 9999, 検索あり/なし, 最終ページ)のテスト観点を箇条書きで出して」です。これだけで自己点検のチェックリストが返ってきて、レビューがそのまま回ります。
実際に試した結果
今回のコードは、page未指定・page=1・page=0・page=abc・page=9999・検索あり・検索なし・最終ページ・1ページしかない検索結果、の9パターンを手元で確認しました。一番効いたのは2つ。page=1をURLから消したこと と、検索条件を変えたとき最終ページに丸めたことです。URLが短くなり、冒頭の「共有したら1ページ目」みたいな事故が消えました。
そして個人的に一番の収穫は、着手前に「オフセットかカーソルか」を口に出して決める癖がついたことです。これを決めずに書き始めると、ログ一覧でオフセットの二重表示にあとから気づいて、結局カーソルに書き直す——という遠回りを、僕は何度もやってきました。先に1分考えるだけで、その丸ごとが消えます。
ページネーションは小さな部品ですが、記事一覧・検索・管理画面・収益導線の全部に効いてきます。Claude Codeには実装だけでなく、方式の選択・URL設計・APIメタ情報・アクセシビリティ・境界値テストまで一度に頼むのがおすすめです。テンプレやチェックリストをそのまま使いたい人は 教材一覧 に整理しています。より広いClaude Code運用は 入門ガイド と プロンプト改善Tips もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。