Use Cases (更新: 2026/6/2)

用 Claude Code 实现分页:React 与 Next.js 实战

用 Claude Code 构建 React/Next.js 分页,覆盖 URL 状态、API 元数据、可访问性与边界测试。

用 Claude Code 实现分页:React 与 Next.js 实战

分页看起来只是“上一页、下一页、页码”几个按钮,但真正上线后容易出问题的地方并不在样式,而在 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 读取 qpage,把页码规范化,保留搜索条件,并把安全值传给 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=-1page=abcpage=9999 都要在服务器端处理。

第二,不要在分页链接里丢掉过滤条件。如果当前是 ?q=react&page=2,下一页必须保留 q=react。最好把链接生成逻辑集中在一个函数里,只替换 page

第三,不要只靠颜色表示当前页。视觉用户可能看得懂,但辅助技术无法得知。当前页只添加一个 aria-current="page",并给 nav 添加清晰标签。

第四,不要忽视计数成本。大表上的精确 COUNT(*) 可能比当前页查询还慢。可以考虑索引、缓存计数、近似计数或限制最大页数。让 Claude Code 评审数据库执行计划,而不只是评审 React。

第五,不要混淆历史记录行为。底层的 pushState() 会向浏览器会话历史中添加记录,MDN 的 History pushState 文档 有详细解释。在 Next.js 中通常使用 Linkrouter.push,但要明确分页是否应该产生新的历史记录。

验证结果

我用这个示例检查了这些情况:没有 pagepage=1page=0page=abcpage=9999、有搜索结果、无搜索结果、最后一页、只有一页的结果集。最有价值的两个细节是:从 URL 中省略 page=1,以及在过滤后把过大的页码夹到最后一页。分享链接更短,数据变化后也不容易出现空屏。

发布前可以让 Claude Code 再检查一遍:是否只有一个 aria-current,上一页和下一页是否在边界禁用,pageSize 是否有上限,过滤条件是否保留,searchParams 是否使用 await,TypeScript 示例是否能解析。分页是小组件,但它影响归档、搜索、后台工具和转化路径。把 URL 状态、API 元数据和可访问性一起实现,结果会可靠很多。

#Claude Code #分页 #React #Next.js #UX
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。