DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Pagination with Claude Code: Cursor-Based vs OFFSET and Infinite Scroll

OFFSET pagination breaks on tables with millions of rows — each page requires scanning all previous rows. Cursor-based pagination stays fast at any scale. Claude Code generates the complete implementation.


CLAUDE.md for Pagination Rules

## Pagination Design Rules

### Method
- Over 10,000 rows: cursor-based required (OFFSET forbidden)
- Admin UIs with small datasets: OFFSET pagination acceptable
- Infinite scroll: cursor-based + hasNextPage

### Cursor-Based
- Cursor must be opaque (don't expose internal structure to clients)
- Encode cursor as Base64 of ID or composite key
- Response includes nextCursor and hasNextPage

### Performance
- Enforce pageSize limit (max 100)
- orderBy required (needed for cursor-based sorting)
- Index cursor column
Enter fullscreen mode Exit fullscreen mode

Generating Cursor Pagination

Generate cursor-based pagination API.

Endpoint: GET /api/users
Parameters: cursor?, limit(1-100, default 20)
Response: { data: User[], nextCursor: string | null, hasNextPage: boolean }

Requirements:
- Prisma cursor pagination
- Base64-encode cursor
- orderBy: createdAt desc, id desc (tiebreaker)
Enter fullscreen mode Exit fullscreen mode

Generated:

// src/utils/pagination.ts
export function encodeCursor(id: string, createdAt: Date): string {
  return Buffer.from(JSON.stringify({ id, createdAt: createdAt.toISOString() })).toString('base64');
}

export function decodeCursor(cursor: string): { id: string; createdAt: Date } | null {
  try {
    const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
    return { id: decoded.id, createdAt: new Date(decoded.createdAt) };
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/services/userService.ts
export async function listUsers(
  limit: number,
  cursor?: string
): Promise<PaginatedResult<User>> {
  const take = Math.min(limit, 100);
  const decoded = cursor ? decodeCursor(cursor) : null;

  const users = await prisma.user.findMany({
    take: take + 1, // Fetch one extra to detect hasNextPage
    ...(decoded && {
      cursor: { id: decoded.id },
      skip: 1,
    }),
    orderBy: [
      { createdAt: 'desc' },
      { id: 'desc' }, // Tiebreaker for same timestamp
    ],
  });

  const hasNextPage = users.length > take;
  const data = hasNextPage ? users.slice(0, take) : users;
  const lastItem = data[data.length - 1];

  return {
    data,
    nextCursor: lastItem ? encodeCursor(lastItem.id, lastItem.createdAt) : null,
    hasNextPage,
  };
}
Enter fullscreen mode Exit fullscreen mode

Frontend Infinite Scroll

// useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';

export function useInfiniteUsers() {
  return useInfiniteQuery({
    queryKey: ['users'],
    queryFn: ({ pageParam }) =>
      fetch(`/api/users?limit=20${pageParam ? `&cursor=${pageParam}` : ''}`).then(r => r.json()),
    getNextPageParam: (lastPage) => lastPage.hasNextPage ? lastPage.nextCursor : undefined,
    initialPageParam: undefined,
  });
}

function UserList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteUsers();
  const ref = useRef(null);

  // Trigger next page when bottom element comes into view
  useIntersectionObserver(ref, () => {
    if (hasNextPage && !isFetchingNextPage) fetchNextPage();
  });

  return (
    <>
      {data?.pages.flatMap(p => p.data).map(user => <UserCard key={user.id} user={user} />)}
      <div ref={ref} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

OFFSET Pagination (Admin Panels Only)

// Only when direct page jumping is required
router.get('/admin/users', async (req, res) => {
  const page = Math.max(1, Number(req.query.page) || 1);
  const limit = 20;

  const [total, users] = await prisma.$transaction([
    prisma.user.count(),
    prisma.user.findMany({
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
    }),
  ]);

  res.json({
    data: users,
    total,
    page,
    totalPages: Math.ceil(total / limit),
  });
});
Enter fullscreen mode Exit fullscreen mode

Summary

Design pagination with Claude Code:

  1. CLAUDE.md — Cursor-based required for 10k+ rows, OFFSET forbidden at scale
  2. Base64 cursor — Hide internal structure from clients
  3. take+1 trick — Fetch one extra item to determine hasNextPage without extra COUNT query
  4. React Query useInfiniteQuery — Built-in infinite scroll with cursor support

Code Review Pack (¥980) includes /code-review to detect pagination issues — OFFSET at scale, unsafe cursor exposure, missing index.

👉 prompt-works.jp

Myouga (@myougatheaxo) — Claude Code engineer focused on scalable API design.

Top comments (0)