Como implementar paginação com Claude Code, React e Next.js
Guia prático de paginação com Claude Code: estado na URL, API, acessibilidade e testes de borda.
Paginação parece um componente pequeno: anterior, próximo e alguns números de página. Em produção, o risco não está no desenho dos botões. O que costuma quebrar é a URL como fonte de verdade, a preservação dos filtros, o tratamento de páginas inválidas, a última página depois de alterações nos dados, os metadados da API e a indicação da página atual para tecnologias assistivas.
Ao testar esse padrão em arquivos de artigos e telas administrativas do ClaudeCodeLab, os mesmos problemas apareceram depois da primeira geração: page=0 mostrava lista vazia, o filtro sumia ao avançar, a última página ficava incorreta depois de exclusões e a página atual era marcada apenas por cor. Claude Code consegue resolver isso, mas precisa receber o contrato completo no prompt inicial.
Este guia mostra como pedir ao Claude Code uma paginação robusta com React e Next.js App Router. Ele inclui prompt, desenho de URL, fatiamento no servidor, rota JSON, componente acessível, mais de três casos de uso, armadilhas concretas, links oficiais, links internos, CTA e nota de verificação prática. Se a lista deve carregar continuamente, compare com scroll infinito. Para revisar contratos de endpoint, leia design de REST API. Para teclado e leitores de tela, consulte acessibilidade com Claude Code.
Escolher o modelo
Há dois modelos principais. Paginação por offset pede “página 3, 10 itens por página”. Ela serve bem para arquivos de artigos, resultados de busca, catálogos e tabelas administrativas, porque cada página pode ter uma URL estável. Paginação por cursor pede “os 10 itens depois deste ID”. Ela é melhor para notificações, logs, chats e timelines que mudam enquanto o usuário lê.
Aqui usamos offset porque ele favorece SEO, histórico do navegador e compartilhamento. Uma URL como /articles?page=3&q=react pode ser aberta diretamente, enviada a outra pessoa e restaurada após recarregar. Para feeds em tempo real, diga explicitamente ao Claude Code para usar cursor; caso contrário, novas linhas podem gerar duplicação ou lacunas.
| Modelo | Melhor uso | Principal risco |
|---|---|---|
| Offset | Artigos, busca, produtos, tabelas admin | A última página muda quando o total muda |
| Cursor | Notificações, logs, chat, timeline | Pular para uma página arbitrária é difícil |
| Scroll infinito | Feeds, galerias, relacionados | Voltar, rodapé e SEO exigem mais cuidado |
A documentação oficial Claude Code Overview descreve Claude Code como uma ferramenta agentic que lê código, edita arquivos, executa comandos e integra ferramentas de desenvolvimento. Por isso, o pedido deve cobrir URL, API, acessibilidade e verificação, não apenas o visual.
Prompt para Claude Code
Paginação cruza UI, roteamento, dados e acessibilidade. O prompt inicial deve deixar claro o que significa “pronto”.
Implemente paginação de lista de artigos com React e Next.js App Router.
Requisitos:
- Usar os parâmetros page e q da URL como fonte de verdade
- Suportar searchParams como Promise em page.tsx no Next.js moderno
- Exibir 10 itens por página; page=0 ou não numérico volta para 1
- Se a página solicitada passar da última, mostrar a última página
- Adicionar aria-current="page" no link da página atual
- Renderizar anterior/próximo desabilitados como span, não como links clicáveis
- Não quebrar frontmatter, heroImage, links internos nem rotas localizadas
- Depois da implementação, listar testes de borda para executar
No App Router moderno do Next.js, searchParams em page.tsx é tratado como Promise. A referência oficial de page.js mostra o acesso com await. Em componentes de cliente, useSearchParams lê a query string, mas retorna uma visão somente leitura de URLSearchParams.
Estado na URL
O exemplo abaixo funciona como Server Component. Ele lê q e page, normaliza a página, preserva o filtro e passa valores seguros ao componente Pagination. Os dados são um array para manter o exemplo copiável; em produção, substitua filtro e slice por uma consulta ao banco.
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>
);
}
O ponto principal é não esconder a página apenas em estado React. Se o estado não está na URL, recarregar, compartilhar, indexar e usar o botão voltar ficam frágeis. URLSearchParams é a API padrão para query strings; a referência base é MDN URLSearchParams.
Rota JSON
Se um app móvel, widget ou tabela cliente precisa dos mesmos dados, exponha uma API com metadados explícitos. Não aceite qualquer pageSize enviado pelo usuário.
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,
},
});
}
Coloque em app/api/articles/route.ts. A documentação oficial de Next.js route handlers explica arquivos route.ts dentro do diretório app. Em produção, a camada de dados deve retornar as linhas visíveis e uma contagem exata ou uma aproximação aceita pelo produto.
Componente acessível
O estilo pode mudar, mas a semântica deve ficar estável. Use nav com rótulo, marque exatamente uma página atual com aria-current="page" e não deixe controles desabilitados como links ativos. A referência MDN aria-current cita paginação como caso de uso.
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>
);
}
Diagrama e revisão
Peça ao Claude Code um diagrama curto depois da implementação. Ele ajuda a ver se URL, página de servidor, lista e componente estão separados.
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"]
A pergunta de revisão é direta: todo o estado pode ser reconstruído pela URL? Se sim, recarregar, voltar, compartilhar e SEO ficam mais confiáveis.
Casos de uso
O primeiro caso é um blog ou documentação. Conforme os tutoriais crescem, a primeira página continua leve e páginas antigas seguem acessíveis por link direto.
O segundo é busca em ecommerce ou SaaS. Texto, categoria, preço, ordenação e página precisam permanecer na URL. Diga ao Claude Code para voltar page para 1 quando filtros mudarem.
O terceiro é uma tabela administrativa: faturas, usuários, formulários ou logs. Aqui importam limite de página, filtros de permissão e coerência com exportação CSV.
O quarto é um painel de aprendizagem. O leitor pode voltar dias depois. Paginação estável preserva posição e permite retornar de uma CTA como folha gratuita ou consultoria Claude Code.
Armadilhas comuns
Não confie no número recebido: page=-1, page=abc e page=9999 devem ser tratados no servidor. Não perca filtros ao gerar links; substitua apenas page. Não marque a página atual só por cor; use exatamente um aria-current="page". Não ignore o custo de contagem em tabelas grandes; COUNT(*) pode exigir índices, cache ou contagem aproximada.
Também decida o comportamento do histórico. A API de baixo nível pushState() adiciona uma entrada ao histórico de sessão do navegador, como explica MDN History pushState. No Next.js, normalmente use Link ou router.push, mas escolha conscientemente entre adicionar ou substituir uma entrada.
Resultado verificado
Verifiquei o exemplo com: sem page, page=1, page=0, page=abc, page=9999, busca com resultados, busca sem resultados, última página e resultado com uma única página. Os detalhes mais úteis foram remover page=1 da URL e ajustar páginas altas depois do filtro. Links compartilhados ficam limpos e telas vazias ficam menos prováveis quando os dados mudam.
Antes de publicar, peça ao Claude Code uma revisão final: apenas um aria-current, anterior/próximo desabilitados nos limites, pageSize com teto, filtros preservados, searchParams lido com await e exemplos TypeScript válidos. Paginação é pequena, mas afeta arquivos, busca, administração e caminhos de conversão.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.