⚡ Data Flow Guide

API Pagination Strategies: Offset, Cursor & Infinite Scroll Explained

9 min read · API Design · TypeScript · React Query · GitHub · Stripe

Choosing the wrong pagination strategy can tank your app's performance at scale. This guide explains offset, cursor, and page-based pagination with TypeScript types, real-world API examples, and a React Query implementation for infinite scroll.

Why Pagination Matters More Than You Think

Every list endpoint in your application is either paginated correctly or it's a future performance disaster waiting to happen. Returning 10,000 records in a single request doesn't just slow down your UI — it puts a massive strain on your database, increases server costs, and makes your app nearly unusable on mobile.

Equally important: the shape of your pagination response directly affects how complex your frontend data fetching logic becomes. A poorly designed response type forces awkward state management, difficult cache invalidation, and brittle infinite scroll implementations.

Comparing the Three Strategies

Offset / Limit

Offset Pagination

✓ Simple to implement
✓ Easy "jump to page N"
✓ Works with SQL OFFSET

✗ Slow at high offsets
✗ Skips/duplicates on inserts
✗ No stable cursor

Cursor Based

Cursor Pagination

✓ Stable at any scale
✓ No duplicates
✓ Ideal for feeds/streams

✗ Can't jump to page N
✗ Complex to implement
✗ Cursor must be opaque

Page / Size

Page-based

✓ Intuitive for users
✓ Total pages visible
✓ Good for tables/grids

✗ Same perf issues as offset
✗ Complex server queries
✗ Not suited for streams

TypeScript Types for Each Strategy

1. Offset Pagination

export interface PaginatedResponse<T> {
  items: T[];
  totalCount: number;
  offset: number;
  limit: number;
  hasNextPage: boolean;
}

// Usage
const response: PaginatedResponse<User> = await fetchUsers({ offset: 20, limit: 10 });
const totalPages = Math.ceil(response.totalCount / response.limit);

2. Cursor-Based Pagination (GitHub / Stripe style)

export interface CursorPaginatedResponse<T> {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
  pageSize: number;
}

// Fetching the next page
async function fetchNextPage(cursor: string | null) {
  if (!cursor) return null; // end of list
  return fetchUsers({ after: cursor, limit: 50 });
}

3. Infinite Scroll (React Query compatible)

export interface InfiniteScrollResponse<T> {
  items: T[];
  nextPage: number | null; // null means no more pages
  isLoading: boolean;
  hasMore: boolean;
}

// With React Query useInfiniteQuery:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = null }) => fetchPosts(pageParam),
  getNextPageParam: (lastPage) => lastPage.nextPage, // ← type-safe!
});
⚡ Pro Tip: Always Include hasMore Even with cursor-based pagination, include a hasMore: boolean field. This prevents an extra round-trip request to discover the end of the list and simplifies your UI logic significantly.

Real World API Examples

GitHub API (Link Header)

export interface GitHubSearchResponse<T> {
  items: T[];
  total_count: number;
  incomplete_results: boolean;
  // Pagination info comes in the "Link" response header:
  // Link: <url>; rel="next", <url>; rel="last"
}

Stripe API (has_more)

export interface StripeList<T> {
  object: 'list';
  url: string;
  has_more: boolean;
  data: T[];
}

// Fetch next page using the last item's ID as cursor:
stripe.customers.list({ starting_after: lastCustomer.id, limit: 100 });

Notion API (next_cursor)

export interface NotionList<T> {
  object: 'list';
  results: T[];
  next_cursor: string | null;
  has_more: boolean;
}
⚠ Database Performance Note SQL OFFSET queries get slower as the offset grows because the database must scan all skipped rows. For large datasets, always prefer cursor-based pagination using an indexed column (like id or created_at) for the cursor value.

Implementing Infinite Scroll with IntersectionObserver

The most performant infinite scroll implementation uses the browser's IntersectionObserver API to detect when a sentinel element enters the viewport:

const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage(); // type-safe: knows nextPage type from your PaginatedResponse
      }
    },
    { threshold: 0.1 }
  );

  if (sentinelRef.current) observer.observe(sentinelRef.current);
  return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);

Frequently Asked Questions

Which strategy should I use for a Twitter-like feed?

Cursor-based pagination is mandatory for real-time feeds. New posts arrive constantly, so an offset-based approach would skip or duplicate content as new items are inserted at the top.

What should I use for an admin data table?

Page-based or offset pagination with a totalPages count. Users need to jump to specific pages and see how many records exist — cursor pagination doesn't support this.

How do I encode a cursor?

Typically, cursors are base64-encoded strings containing a unique record identifier (like the database primary key or a combination of id + created_at). Never expose raw database IDs directly.

Is useInfiniteQuery compatible with these types?

Yes. React Query's useInfiniteQuery and TanStack Query v5's useSuspenseInfiniteQuery both work seamlessly with the generated types. The getNextPageParam function is fully type-safe when your response type includes nextCursor or nextPage.

Should the cursor be server-side or client-side?

Always server-side. The server generates and validates the cursor. The client treats it as an opaque string and passes it back unchanged. This prevents cursor manipulation and keeps pagination logic encapsulated.

Generate Your Pagination Types

Select your strategy, pick a preset (GitHub, Stripe, Notion), and copy ready-to-use TypeScript in seconds.

Open Pagination Generator →