Claude Code로 페이지네이션 구현하기: React와 Next.js 실전
Claude Code로 React/Next.js 페이지네이션을 URL 상태, API 메타데이터, 접근성까지 구현하는 방법.
페이지네이션은 겉으로는 단순합니다. 이전, 다음, 그리고 몇 개의 페이지 번호만 있으면 되는 것처럼 보입니다. 하지만 운영 환경에서 문제가 되는 부분은 버튼 모양이 아니라 URL 상태, 검색 조건 유지, 잘못된 페이지 번호 처리, 마지막 페이지 변화, API 메타데이터, 그리고 스크린 리더가 현재 페이지를 알 수 있는지입니다.
Claude Code에 “React 페이지네이션 만들어 줘”라고만 요청하면 데모로는 괜찮은 코드가 나옵니다. 그러나 ClaudeCodeLab의 글 목록과 관리자형 테이블에서 직접 점검해 보니, 첫 결과물에는 page=0이 빈 화면을 만들거나, 검색어가 다음 페이지 링크에서 사라지거나, 현재 페이지가 색상으로만 표시되는 문제가 자주 있었습니다. 처음부터 경계 조건과 검증 기준을 프롬프트에 넣어야 수정 횟수가 줄어듭니다.
이 글은 React와 Next.js App Router를 기준으로 Claude Code에 페이지네이션을 맡기는 실전 흐름을 정리합니다. 프롬프트, URL 설계, 서버 측 페이지 분리, JSON API, 접근 가능한 컴포넌트, 3개 이상의 사용 사례, 구체적인 함정, 공식 링크, 내부 링크, CTA, 직접 검증 메모까지 포함합니다. 자동으로 계속 불러오는 목록이 필요하다면 무한 스크롤 구현을 비교해 보세요. API 전체 구조는 REST API 설계 가이드, 키보드와 보조기술 점검은 접근성 구현과 함께 보면 좋습니다.
먼저 모델을 고른다
페이지네이션에는 대표적으로 offset 방식과 cursor 방식이 있습니다. Offset은 “3페이지, 페이지당 10개”처럼 번호로 데이터를 가져옵니다. 글 목록, 검색 결과, 상품 목록, 관리자 테이블처럼 각 페이지가 공유 가능한 URL을 가져야 하는 화면에 잘 맞습니다. Cursor는 “이 ID 다음의 10개”처럼 기준점을 넘깁니다. 알림, 감사 로그, 채팅, 타임라인처럼 사용자가 보는 동안 새 데이터가 들어오는 목록에 더 안전합니다.
이 글에서는 SEO와 공유 링크에 강한 offset 방식을 중심으로 설명합니다. /articles?page=3&q=react 같은 URL은 바로 열 수 있고, 팀원에게 보낼 수 있으며, 새로고침 후에도 상태가 복원됩니다. 반대로 실시간 feed라면 Claude Code에 cursor 방식을 명시해야 합니다. 그렇지 않으면 새 레코드가 끼어들 때 중복이나 누락이 생길 수 있습니다.
| 모델 | 잘 맞는 화면 | 주의할 점 |
|---|---|---|
| Offset | 글 목록, 검색 결과, 상품 목록, 관리자 테이블 | 총 개수가 바뀌면 마지막 페이지가 이동함 |
| Cursor | 알림, 감사 로그, 채팅, 타임라인 | 임의 페이지로 바로 이동하기 어려움 |
| 무한 스크롤 | 피드, 갤러리, 추천 목록 | 뒤로 가기, 푸터 접근, SEO 처리가 어려움 |
Claude Code 공식 Overview는 Claude Code를 코드베이스를 읽고, 파일을 편집하고, 명령을 실행하며 개발 도구와 통합되는 agentic coding tool로 설명합니다. 그러므로 UI만 요청하지 말고 URL, API, 접근성, 검증 기준까지 한 번에 전달하는 편이 좋습니다.
Claude Code에 줄 프롬프트
페이지네이션은 UI, 라우팅, 데이터 조회, 접근성이 모두 얽혀 있습니다. 첫 프롬프트에서 완료 조건을 고정하세요. 다음 예시는 Next.js의 최신 App Router 동작과 잘못된 입력 처리까지 포함합니다.
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 컴포넌트에 전달합니다. 예시는 배열 데이터를 사용하므로 복사해서 이해하기 쉽고, 실제 서비스에서는 같은 규칙을 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에만 두면 새로고침, 링크 공유, 검색엔진 크롤링, 브라우저 뒤로 가기가 모두 불안정해집니다. 쿼리 문자열을 다루는 표준 API는 URLSearchParams이며, 동작 기준은 MDN URLSearchParams에서 확인할 수 있습니다.
JSON API가 필요한 경우
모바일 앱, 대시보드 위젯, 클라이언트 테이블이 같은 데이터를 사용해야 한다면 JSON API도 준비합니다. 이때 pageSize에는 반드시 상한이 있어야 합니다. 사용자가 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를 정의하는 방식을 설명합니다. 실무에서는 배열 대신 DB에서 현재 페이지 데이터와 총 개수 또는 의도적으로 계산한 근사치를 반환합니다.
접근 가능한 컴포넌트
스타일은 바꿀 수 있어도 의미 구조는 고정해야 합니다. 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에 남아야 팀원에게 같은 결과를 공유할 수 있습니다. 필터가 바뀔 때 page를 1로 되돌리라고 Claude Code에 명시하세요.
세 번째는 관리자 테이블입니다. 청구서, 사용자, 문의, 감사 기록에는 페이지 크기 제한, 권한 필터, CSV 내보내기와의 일관성이 필요합니다. 표와 내보내기가 다른 조건을 쓰면 운영자가 결과를 믿기 어렵습니다.
네 번째는 학습 대시보드입니다. 독자가 며칠에 나눠 튜토리얼을 읽을 수 있으므로 안정적인 URL이 중요합니다. 무료 치트시트나 Claude Code 도입 상담으로 이동했다가 돌아와도 위치를 잃지 않아야 합니다.
흔한 함정
첫째, 들어온 페이지 번호를 믿지 마세요. page=-1, page=abc, page=9999는 모두 서버에서 처리해야 합니다.
둘째, 링크를 만들 때 필터를 떨어뜨리지 마세요. ?q=react&page=2에서 다음 페이지는 q=react를 유지해야 합니다. 링크 생성 함수를 한곳에 두고 page만 바꾸는 구조가 좋습니다.
셋째, 현재 페이지를 색상으로만 표현하지 마세요. 보조기술에는 전달되지 않습니다. aria-current="page"는 정확히 하나만 붙입니다.
넷째, 총 개수 계산 비용을 무시하지 마세요. 큰 테이블에서 정확한 COUNT(*)는 비쌀 수 있습니다. 인덱스, 캐시된 카운트, 근사치, 최대 페이지 제한을 검토하세요.
다섯째, 히스토리 동작을 우연에 맡기지 마세요. 낮은 수준의 pushState()는 브라우저 세션 히스토리에 항목을 추가합니다. 자세한 동작은 MDN History pushState를 참고하고, Next.js에서는 보통 Link나 router.push를 명시적으로 사용합니다.
검증 결과
이 예시는 page 없음, page=1, page=0, page=abc, page=9999, 검색 결과 있음, 검색 결과 없음, 마지막 페이지, 결과가 한 페이지뿐인 경우를 확인했습니다. 가장 효과가 컸던 것은 page=1을 URL에서 제거하고, 필터 후 너무 큰 페이지를 마지막 페이지로 보정한 점입니다. 공유 링크가 짧아지고 데이터 변경 후 빈 화면이 줄었습니다.
게시 전에 Claude Code에 다시 확인시킬 항목은 명확합니다. aria-current가 하나뿐인지, 이전/다음이 경계에서 비활성화되는지, pageSize 상한이 있는지, 필터가 유지되는지, searchParams를 await하는지, TypeScript 예제가 구문 오류 없이 읽히는지 확인하세요. 페이지네이션은 작은 컴포넌트지만 아카이브, 검색, 관리자 도구, 전환 경로 모두에 영향을 줍니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.