TL;DR: TanStack Query's
useInfiniteQueryhandles cursor and offset pagination with built-in state management for page accumulation, direction, and prefetching. This guide covers cursor-based pagination with TanStack Start server functions, reverse pagination for chat-like UIs, stale-while-revalidate for infinite lists, and optimistic prepending for real-time updates.
Introduction
Pagination is deceptively complex. Offset pagination breaks when items are added or removed. Cursor pagination requires careful state management. Infinite scroll has edge cases with prefetching, race conditions, and memory growth.
TanStack Query's useInfiniteQuery abstracts most of this complexity. It manages page accumulation, provides fetchNextPage and fetchPreviousPage callbacks, and integrates naturally with TanStack Router's preloading system.
Cursor Pagination with Server Functions
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '../db'
export const listProductsFn = createServerFn({ method: 'GET' })
.validator(z.object({
cursor: z.string().optional(),
limit: z.number().default(20),
}))
.handler(async ({ data }) => {
const { cursor, limit } = data
const products = await db
.selectFrom('products')
.orderBy('createdAt', 'desc')
.limit(limit + 1)
.cursor(cursor ? { after: cursor } : undefined)
.execute()
const hasMore = products.length > limit
const items = hasMore ? products.slice(0, limit) : products
const nextCursor = hasMore ? items[items.length - 1].id : null
return { items, nextCursor }
})
useInfiniteQuery Hook
import { useInfiniteQuery } from '@tanstack/react-query'
import { listProductsFn } from '../server/products'
function ProductList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam }) =>
listProductsFn({ data: { cursor: pageParam } }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
return (
<div>
{data?.pages.map((page) =>
page.items.map((product) => <ProductCard key={product.id} product={product} />)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}
Infinite Scroll with Intersection Observer
function InfiniteProductList() {
const observerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 }
)
if (observerRef.current) observer.observe(observerRef.current)
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
return (
<div>
{items}
<div ref={observerRef} />
</div>
)
}
Pagination Strategy Comparison
| Strategy | Pros | Cons | Best For |
|---|---|---|---|
| Offset (LIMIT/OFFSET) | Simple, skip to page N | Breaks on insert/delete | Static datasets |
| Cursor (keyset) | Stable, fast on large data | Cannot skip pages | Dynamic feeds |
| Time-based | Natural ordering | Uneven page sizes | Activity streams |
| Search-after (elastic) | Works with full-text | DB-specific | Search results |
Conclusion
useInfiniteQuery with cursor-based pagination provides the best UX for dynamic data feeds. Combined with TanStack Start server functions, you get type-safe pagination from database to UI with minimal boilerplate.
For server-side pagination patterns, see TanStack Query: Server Functions Best Practices. Master cache management with Cache Invalidation Strategies. If you are rendering large paginated lists, TanStack Virtual Scrolling pairs naturally with infinite queries, and the SaaS Database Architecture guide covers the underlying data structures for efficient pagination.