Claude Codeでページネーションを実装する実践ガイド
Claude CodeでReact/Next.jsのページネーションを作る手順を、API設計、URL状態、アクセシビリティまで解説。
ページネーションは「前へ」「次へ」「1 2 3」と並べるだけのUIに見えます。しかし実務で壊れやすいのは、ボタンの見た目ではなく、URLにページ番号を残すこと、検索条件を維持すること、存在しないページを安全に丸めること、スクリーンリーダーへ現在ページを伝えること、APIの件数計算を信用できる形にすることです。
Claude Codeに「ページネーションを作って」とだけ頼むと、デモとしては動くコードが出ます。ただしMasaが記事一覧や管理画面で試した範囲では、page=0、削除後の最終ページ、検索条件付きの戻るボタン、aria-currentの抜けが後から修正対象になりがちでした。最初の依頼文に境界条件と検証方法まで入れるほうが、手戻りは明らかに減ります。
この記事では、ReactとNext.js App Routerを前提に、Claude Codeへ渡すプロンプト、URL設計、サーバー側のページ切り出し、JSON API、アクセシブルなコンポーネント、3つ以上のユースケース、落とし穴、公開前チェックまでまとめます。大量の一覧を自動で追加読み込みしたい場合は無限スクロール実装、API全体の設計を見直したい場合はREST API設計ガイド、読み上げやキーボード操作はアクセシビリティ実装も合わせて確認してください。
まず決める設計
最初に、ページネーションの方式を分けます。オフセット方式は「3ページ目、10件ずつ」のように番号で指定する方式です。記事一覧、検索結果、管理画面のテーブルに向いています。カーソル方式は「このIDの次から10件」のように基準点を渡す方式で、通知、ログ、チャット、時系列フィードのように新しい行が増え続ける一覧に向いています。
この記事の主役は、SEOと共有URLに強いオフセット方式です。検索結果や記事アーカイブでは、/articles?page=3&q=reactのようなURLをそのまま共有できることが重要です。一方で、リアルタイムに増減する監査ログではカーソル方式のほうが重複や取りこぼしを避けやすいので、Claude Codeには用途を明示してから実装させます。
| 方式 | 向いている画面 | 注意点 |
|---|---|---|
| オフセット | 記事一覧、検索結果、商品一覧、管理テーブル | 件数が変わると最終ページがずれる |
| カーソル | 通知、監査ログ、チャット履歴、時系列フィード | 任意ページへ直接移動しにくい |
| 無限スクロール | SNS風フィード、ギャラリー、関連記事 | 戻る操作、フッター到達、SEO対策が難しい |
公式情報では、Claude Codeはコードベースを読み、ファイル編集やコマンド実行を行うエージェント型の開発ツールとして説明されています。作業の前提を細かく渡すほど、既存構成に合わせた変更にしやすくなります。現在の概要はClaude Code公式Overviewで確認できます。
Claude Codeへの依頼文
ページネーションはUI、URL、データ取得、アクセシビリティがまたがるため、最初のプロンプトで完了条件を固定します。「Reactで作って」ではなく、どのURLを正とするか、Next.jsのどのルーターを使うか、異常値をどう扱うかまで指定します。
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、内部リンクを壊さない
- 実装後に境界値のテスト観点を箇条書きで出す
Next.jsの現在のApp Routerでは、page.tsxのsearchParamsはPromiseとして扱うのが新しい書き方です。公式のpage.jsリファレンスでも、searchParamsはPromiseで、awaitして読むことが示されています。クライアントコンポーネントでURLを読む場合はuseSearchParamsを使いますが、これは読み取り専用のURLSearchParamsを返す点に注意します。
URL状態とページ切り出し
まずはサーバーコンポーネントだけで動く一覧を作ります。検索語qとページ番号pageをURLから読み、ページ番号を安全に丸め、検索条件を維持したままPaginationへ渡します。サンプルは配列データですが、実務では同じ形でDBクエリに置き換えます。
import { Pagination } from "@/components/Pagination";
const PAGE_SIZE = 10;
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;
}>;
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;
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だけでページ番号を持つと、戻るボタン、共有リンク、再読み込み、検索エンジンのクロールで状態が消えます。URLSearchParamsはクエリ文字列を扱う標準APIで、MDNではクエリ文字列を操作するためのユーティリティメソッドを提供するインターフェースとして説明されています。仕様確認はMDN URLSearchParamsが基準になります。
JSON APIを用意する場合
同じページングロジックをJSON APIとして公開するなら、pageとpageSizeを必ず上限付きで読みます。Claude Codeがよく出す失敗は、pageSize=100000をそのまま許すコードです。これはDBにもレスポンスにも負荷をかけます。
import type { NextRequest } from "next/server";
const MAX_PAGE_SIZE = 50;
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,
},
});
}
このAPIはapp/api/articles/route.tsに置けます。Next.jsのroute handler公式ドキュメントでは、Route Handlerをapp配下のroute.tsやroute.jsで定義することが説明されています。実務では配列の代わりにcountとfindManyをDBで実行しますが、pageSizeの上限、safePage、metaの形はそのまま使えます。
アクセシブルなコンポーネント
ページネーションの見た目は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);
}
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します。useSearchParamsの戻り値は読み取り専用なので、直接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) {
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 };
}
全体像の図
コード量が増えたら、Claude Codeには実装後に小さな図も出させます。人間のレビューでは、URL、サーバーコンポーネント、API、コンポーネントの責務が混ざっていないかを確認しやすくなります。
flowchart LR
A["URL: /articles?page=3&q=react"] --> B["Page searchParams"]
B --> C["readPage and filter"]
C --> D["slice visible items"]
D --> E["Article list"]
C --> F["Pagination component"]
F --> A
C --> G["Optional JSON API meta"]
この図のレビュー観点は単純です。URLが正本であること、ページ番号の丸めがサーバー側にあること、UIコンポーネントがDBを知らないこと、APIレスポンスにtotalPagesとhasNextPageが含まれることを見ます。
実務でのユースケース
1つ目はブログやドキュメントのアーカイブです。記事数が増えても初期表示を軽くでき、page=4のようなURLを検索結果やSNSから直接開けます。AdSenseやSEOを考える場合、ページごとに通常リンクで到達できることが大切です。
2つ目はECやSaaSの検索結果です。検索語、カテゴリ、価格帯、並び順をURLに残せば、チームメンバーへ同じ結果を共有できます。Claude Codeには「フィルター変更時はpageを1へ戻す」と明示します。これを書かないと、5ページ目で絞り込みを変えて0件に見える事故が起きます。
3つ目は管理画面のテーブルです。請求一覧、ユーザー一覧、問い合わせ一覧では、ページサイズの上限、権限フィルター、CSV出力との整合性が重要です。表示ページとエクスポート件数が違うと運用事故になるため、metaを共通化しておきます。
4つ目は学習コンテンツやチュートリアル一覧です。読者が数日に分けて戻ってくるため、URLで続きの位置を復元できると離脱が減ります。CTAとしてClaude Code研修・導入相談や無料チートシートへつなぐ場合も、一覧の途中から戻れる設計が効きます。
よくある落とし穴
最初の落とし穴は、ページ番号を信頼しすぎることです。page=-1、page=abc、page=9999は必ず来ます。UIから出さない値でも、URLを直接編集すれば送れます。サーバー側で1以上の整数に丸め、最終ページを超えたら最後のページを表示します。
2つ目は、検索条件をページリンクから落とすことです。q=react&page=2で2ページ目へ進んだのに、次リンクが?page=3だけになると検索条件が消えます。ページリンクを作る関数は必ず既存のクエリを受け取り、pageだけを差し替えます。
3つ目は、現在ページを色だけで表すことです。視覚的には分かっても、支援技術には伝わりません。aria-current="page"を一つだけ付けること、navのラベルを付けること、無効なリンクを本物のリンクにしないことをレビュー項目に入れます。
4つ目は、件数クエリを毎回重くすることです。大規模DBでCOUNT(*)が高い場合は、検索条件に合わせたインデックス、概算件数、上限付きページ表示を検討します。Claude Codeには「DBの実行計画を確認する観点も出して」と頼むと、UIだけで終わりにくくなります。
5つ目は、戻るボタンの体験です。クライアントstateだけでページを変えると、ブラウザ履歴に残らないことがあります。pushStateはブラウザのセッション履歴にエントリを追加するAPIです。低レベルの挙動はMDN History pushStateで確認できますが、Next.jsでは通常Linkかrouter.pushを使い、履歴に残すか置換するかを明示します。
検証結果と公開前チェック
今回のサンプルは、page未指定、page=1、page=0、page=abc、page=9999、検索語あり、検索語なし、最終ページ、1ページしかない検索結果で手元確認しました。特に効果が大きかったのは、page=1をURLから消すことと、検索条件変更時に最終ページを丸めることです。URLが短くなり、共有リンクの見た目も安定しました。
Claude Codeに最後のレビューを頼むなら、次の観点を渡します。aria-currentが一つだけか、前後リンクが境界で無効化されるか、pageSizeに上限があるか、URLクエリを落としていないか、Next.js 15以降のsearchParams Promiseに対応しているか、コード例のTypeScriptが構文エラーにならないか、を確認します。
ページネーションは小さな部品ですが、記事一覧、検索、管理画面、収益導線のすべてに影響します。Claude Codeに実装だけでなく、URL設計、APIメタ情報、アクセシビリティ、境界値テストまで同時に依頼すると、公開後の修正が減ります。より広いClaude Code運用は入門ガイドとプロンプト改善Tipsも参考にしてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。