Implement Pagination with Claude Code, React, and Next.js
Build production pagination with Claude Code, React, Next.js, URL state, API metadata, and accessibility.
Pagination looks like a small UI pattern: previous, next, and a row of page numbers. In production, the hard part is not drawing the buttons. The hard part is deciding that the URL is the source of truth, preserving search filters, clamping invalid page numbers, exposing the current page to assistive technology, and returning API metadata that your UI can trust.
When I tested this pattern on ClaudeCodeLab article archives and admin-style lists, the same bugs kept appearing after the first draft: page=0 returned an empty list, the last page broke after records were deleted, search filters disappeared from page links, and the current page was visible only through color. Claude Code can fix all of those, but only if the prompt describes the boundaries before implementation starts.
This guide shows a practical workflow for asking Claude Code to implement pagination in React and the Next.js App Router. It includes the prompt, URL design, server-side page slicing, a JSON API route, an accessible pagination component, more than three real use cases, concrete pitfalls, official links, internal links, a CTA, and a hands-on verification note. If your list should load continuously, compare this with infinite scroll implementation. For broader API structure, read REST API design with Claude Code. For keyboard and screen reader details, pair it with accessibility implementation.
Design Decisions
Start by choosing the pagination model. Offset pagination asks for “page 3 with 10 items per page.” It is the best default for article archives, search results, product grids, and admin tables because every page can have a shareable URL. Cursor pagination asks for “the next 10 items after this ID.” It is stronger for notifications, audit logs, chats, and timelines where new records are inserted while the user is reading.
This article focuses on offset pagination because it works well for SEO, browser history, and collaborative sharing. A URL such as /articles?page=3&q=react can be opened directly, sent to a teammate, crawled by a search engine, and restored after refresh. For a live feed, tell Claude Code to use a cursor instead; otherwise you will see duplicate or missing records when the list changes during pagination.
| Model | Good fit | Main risk |
|---|---|---|
| Offset | Articles, search results, product lists, admin tables | Last page can move when item counts change |
| Cursor | Notifications, audit logs, chat history, timelines | Direct arbitrary page jumps are harder |
| Infinite scroll | Feeds, galleries, related content | Back behavior, footer access, and SEO need extra work |
The current official Claude Code overview describes Claude Code as an agentic coding tool that can read a codebase, edit files, run commands, and integrate with development tools. That is exactly why the prompt should include URL, API, and accessibility constraints. Otherwise the agent may produce a nice isolated component while missing the surrounding contract.
Prompt Claude Code
Pagination crosses UI, routing, data fetching, and accessibility. The first prompt should define the definition of done. Avoid a vague request such as “make pagination in React.” Tell Claude Code which URL parameters are canonical, which Next.js router you use, how invalid input should behave, and which review checks must pass.
Implement article-list pagination with React and the Next.js App Router.
Requirements:
- Treat URL page and q parameters as the source of truth.
- Support the modern Next.js searchParams Promise in page.tsx.
- Use 10 items per page. Clamp page=0 and non-numeric values to page 1.
- If the requested page is beyond the last page, display the last page.
- Add aria-current="page" to the current page link.
- Render disabled previous/next controls as spans, not clickable links.
- Preserve existing frontmatter, heroImage, internal links, and localized routes.
- After implementation, list boundary test cases I should run.
Modern Next.js App Router pages receive searchParams as a Promise. The official page.js reference shows using await to access it. Client components can read the current query string with useSearchParams, but that hook returns a read-only URLSearchParams view, so you create a new instance before changing values.
URL State
The server-rendered page below keeps the URL as the canonical state. It reads q and page, normalizes the page number, preserves the search query, and passes the safe values to a reusable Pagination component. The data source is an array so the snippet is copy-pasteable. In a real app, replace the filtering and slicing with a database query that uses the same rules.
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>
);
}
The important decision is that React state is not the source of truth. If pagination lives only in component state, browser refresh, copy-paste URLs, search indexing, and back navigation all become weaker. The standard URLSearchParams API exists for query string work; MDN’s URLSearchParams documentation is the baseline reference for how its methods behave.
API Route
If a mobile app, dashboard widget, or client-side table also needs the same data, expose a JSON API with explicit metadata. Do not let Claude Code accept any pageSize a user sends. A request such as pageSize=100000 can create unnecessary database and response load.
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,
},
});
}
Place this in app/api/articles/route.ts. The official Next.js route handler documentation covers route.ts files under the app directory. In production, your database layer should return both the visible rows and either an exact or intentionally approximate count. The UI should not infer totalPages from the current page length.
Accessible Component
The component can be visually simple, but its semantics must be precise. Use a labeled nav, mark exactly one current page with aria-current="page", and do not render disabled previous or next controls as live links. The MDN aria-current reference specifically calls out pagination links as a case for 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>
);
}
For a client-only table, read the current query with useSearchParams, then create a new URLSearchParams before writing. This preserves filters and produces a real navigation entry through 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 };
}
Flow Diagram
Ask Claude Code for a small diagram after implementation. It makes review faster because responsibilities are visible: URL parsing stays in the page, list slicing stays in data logic, and the component only builds links.
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"]
The review question is simple: can every state be reconstructed from the URL? If the answer is yes, refresh, back navigation, sharing, and SEO all become easier. If a hidden React state is required to know which page the user is on, the design is fragile.
Use Cases
The first use case is a blog or documentation archive. As the number of Claude Code tutorials grows, the initial page stays light while older pages remain reachable by direct links. This is better for search and for readers who return to a saved result.
The second use case is ecommerce or SaaS search. Query text, category, price, sort order, and page should remain in the URL. Tell Claude Code to reset page to 1 when filters change; otherwise users can apply a narrow filter while stuck on page 5 and think there are no results.
The third use case is an admin table: invoices, users, form submissions, and audit records. Here you need page-size limits, permission filters, and consistent export behavior. If the table and CSV export do not share the same query contract, operations teams lose trust in the tool.
The fourth use case is a learning dashboard. Readers may open a tutorial list over several sessions. Stable pagination lets them come back to the same position and still reach a CTA such as Claude Code training or a free cheat sheet.
Pitfalls
The first pitfall is trusting the incoming page number. Users can edit URLs, crawlers can request old links, and analytics tools can append strange query values. Always clamp page=-1, page=abc, and page=9999 on the server side.
The second pitfall is dropping filters from generated links. If the user is on ?q=react&page=2, the next link must keep q=react. Centralize the link builder so every control replaces only the page parameter.
The third pitfall is using color as the only current-page signal. Sighted users may understand it, but assistive technology will not. Add aria-current="page" to exactly one link and include a useful nav label.
The fourth pitfall is expensive counting. On very large tables, an exact count can be slower than the visible page query. Use indexes, cached counts, approximate counts, or capped page ranges when the product can accept them. Ask Claude Code to review the database execution plan, not only the React component.
The fifth pitfall is confusing history behavior. The low-level pushState() API adds an entry to the browser session history stack, as described by MDN’s History pushState documentation. In Next.js, prefer Link or router.push, but decide intentionally whether a page change should push history or replace the current entry.
Verification Result
I checked the sample against these cases: no page, page=1, page=0, page=abc, page=9999, search with results, search with no results, the last page, and a result set with only one page. The most useful details were removing page=1 from URLs and clamping a too-large requested page after filtering. Both make shared links cleaner and avoid empty screens after records change.
Before publishing, ask Claude Code to review whether exactly one link has aria-current, previous and next controls disable at boundaries, pageSize is capped, filters are preserved, Next.js searchParams is awaited, and the TypeScript examples parse. Pagination is a small component, but it affects archives, search, admin tools, and revenue paths. Treating URL state, API metadata, and accessibility as one implementation unit gives you a far more durable result.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.