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 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 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-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!
});
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;
}
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 →