用 Claude Code 实现分页:React 与 Next.js 实战
用 Claude Code 构建 React/Next.js 分页,覆盖 URL 状态、API 元数据、可访问性与边界测试。
分页看起来只是“上一页、下一页、页码”几个按钮,但真正上线后容易出问题的地方并不在样式,而在 URL 状态、搜索条件保留、非法页码处理、最后一页变化、API 元数据,以及屏幕阅读器能否知道当前页。只让 Claude Code “做一个分页组件”,通常会得到一个能演示的版本,却未必能承受真实流量和真实用户行为。
我在 ClaudeCodeLab 的文章列表和类似后台表格里测试过这个模式。第一次生成后最常见的问题是:page=0 返回空白、删除数据后最后一页失效、点击下一页后搜索关键词丢失、当前页只靠颜色区分。Claude Code 可以修好这些问题,但前提是你在提示词里先说明边界条件和验收标准。
本文用 React 和 Next.js App Router 实现一个可复制的分页方案。内容包括提示词、URL 设计、服务器端切片、JSON API、可访问分页组件、三个以上的使用场景、常见坑、官方资料、内部链接、CTA,以及实际验证记录。如果你的页面更适合连续加载,可以对照无限滚动实现。如果要先梳理接口风格,可以阅读REST API 设计指南。键盘操作和读屏体验则建议结合可访问性实现一起检查。
先确定分页模型
分页主要有两类。Offset 分页用“第 3 页,每页 10 条”这种方式请求数据,适合文章归档、搜索结果、商品列表和后台表格,因为每一页都能有明确 URL。Cursor 分页用“从这个 ID 之后再取 10 条”这种方式,更适合通知、审计日志、聊天记录和时间线,因为这些列表在用户阅读时可能继续新增记录。
本文重点使用 Offset 分页,因为它对 SEO、浏览器历史记录和分享链接更友好。/articles?page=3&q=react 这样的 URL 可以直接打开、发给同事、被搜索引擎抓取,也可以在刷新后恢复状态。如果你要做实时变化的 feed,就应该明确要求 Claude Code 使用 cursor,否则数据变化时可能出现重复或遗漏。
| 模型 | 适合场景 | 主要风险 |
|---|---|---|
| Offset | 文章、搜索结果、商品列表、后台表格 | 数据总数变化后最后一页会移动 |
| Cursor | 通知、审计日志、聊天、时间线 | 难以直接跳到任意页 |
| 无限滚动 | Feed、图库、相关推荐 | 返回行为、页脚访问、SEO 更难处理 |
官方的 Claude Code Overview 把 Claude Code 描述为可以读取代码库、编辑文件、运行命令并接入开发工具的 agentic coding tool。也就是说,你不应该只让它写 UI,而要让它同时理解路由、API、可访问性和验证规则。
给 Claude Code 的提示词
分页横跨 UI、路由、数据读取和可访问性。最初的提示词要定义完成标准,而不是只说“用 React 写分页”。下面这个提示词把 URL、Next.js 版本行为、异常页码和验收项都写清楚。
请用 React 和 Next.js App Router 实现文章列表分页。
要求:
- 以 URL 的 page 和 q 参数作为真实状态来源
- 支持 Next.js 15 之后 page.tsx 中 searchParams 是 Promise 的写法
- 每页 10 条,page=0 或非数字时回退到第 1 页
- 请求页超过最后一页时显示最后一页
- 当前页链接添加 aria-current="page"
- 上一页/下一页禁用时渲染为 span,不要渲染为可点击链接
- 不破坏现有 frontmatter、heroImage、内部链接和多语言路由
- 实现后列出需要手动检查的边界测试
当前 Next.js App Router 的 page.tsx 会把 searchParams 作为 Promise 传入。官方 page.js reference 展示了用 await 读取它的写法。客户端组件可以用 useSearchParams 读取查询字符串,但返回值是只读的 URLSearchParams,所以修改时要新建实例。
URL 状态与服务器切片
下面的页面只依赖服务器组件即可运行。它从 URL 读取 q 和 page,把页码规范化,保留搜索条件,并把安全值传给 Pagination 组件。示例用数组作为数据源,方便复制运行;真实项目里可以把过滤和切片换成数据库查询。
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 URLSearchParams。
JSON API 示例
如果移动端、仪表盘小组件或客户端表格也要使用同一份数据,可以暴露 JSON API。这里一定要限制 pageSize。Claude Code 有时会生成直接接受用户传入页大小的代码,pageSize=100000 会给数据库和响应体都带来压力。
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,
},
});
}
这段代码可以放在 app/api/articles/route.ts。Next.js 官方的 route handler 文档 说明了在 app 目录下使用 route.ts 定义 Route Handler 的方式。实际项目中,数据库层应同时返回当前页数据和可信的总数或近似总数,UI 不应该靠当前页长度猜测总页数。
可访问的分页组件
分页组件可以有不同样式,但语义要稳定。使用带标签的 nav,当前页只标记一个 aria-current="page",禁用的上一页/下一页不要渲染成仍可点击的链接。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 导航。这样既保留过滤条件,也能让浏览器历史记录保持正确。
"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 还原?如果可以,刷新、返回、分享和 SEO 都会更稳。如果必须依赖隐藏的 React state 才知道用户在哪一页,这个设计就比较脆弱。
实际使用场景
第一个场景是博客或文档归档。Claude Code 教程越来越多时,第一页仍然保持轻量,旧文章也可以通过直接链接访问。这对搜索流量和收藏后返回的读者都很重要。
第二个场景是电商或 SaaS 搜索。关键词、分类、价格、排序和页码都应该保留在 URL 中。要特别告诉 Claude Code:过滤条件改变时把 page 重置为 1。否则用户可能停在第 5 页再缩小筛选范围,看起来像是没有结果。
第三个场景是后台管理表格,例如发票、用户、表单提交、审计记录。这里需要页大小上限、权限过滤和导出逻辑一致。如果表格和 CSV 导出使用不同查询条件,运营团队很快就会失去信任。
第四个场景是学习内容列表。读者可能分几天学习教程,稳定分页能让他们回到同一位置。如果要引导到 Claude Code 培训 或免费速查表,也不应该因为返回列表而丢失位置。
常见坑
第一,不要相信传入的页码。用户可以直接改 URL,爬虫也会请求旧链接。page=-1、page=abc、page=9999 都要在服务器端处理。
第二,不要在分页链接里丢掉过滤条件。如果当前是 ?q=react&page=2,下一页必须保留 q=react。最好把链接生成逻辑集中在一个函数里,只替换 page。
第三,不要只靠颜色表示当前页。视觉用户可能看得懂,但辅助技术无法得知。当前页只添加一个 aria-current="page",并给 nav 添加清晰标签。
第四,不要忽视计数成本。大表上的精确 COUNT(*) 可能比当前页查询还慢。可以考虑索引、缓存计数、近似计数或限制最大页数。让 Claude Code 评审数据库执行计划,而不只是评审 React。
第五,不要混淆历史记录行为。底层的 pushState() 会向浏览器会话历史中添加记录,MDN 的 History pushState 文档 有详细解释。在 Next.js 中通常使用 Link 或 router.push,但要明确分页是否应该产生新的历史记录。
验证结果
我用这个示例检查了这些情况:没有 page、page=1、page=0、page=abc、page=9999、有搜索结果、无搜索结果、最后一页、只有一页的结果集。最有价值的两个细节是:从 URL 中省略 page=1,以及在过滤后把过大的页码夹到最后一页。分享链接更短,数据变化后也不容易出现空屏。
发布前可以让 Claude Code 再检查一遍:是否只有一个 aria-current,上一页和下一页是否在边界禁用,pageSize 是否有上限,过滤条件是否保留,searchParams 是否使用 await,TypeScript 示例是否能解析。分页是小组件,但它影响归档、搜索、后台工具和转化路径。把 URL 状态、API 元数据和可访问性一起实现,结果会可靠很多。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。