client pagination ages badly
Client-side pagination looks harmless during the prototype phase, usually because the first version only has fifty rows, one happy-path filter, and a product manager testing on a MacBook Pro with a warm local API. Then the thing ships, somebody loads page 37 of a customer ledger, the browser holds onto every previous page because your React state tree never really forgets anything, and suddenly you have a UI that feels busy while doing almost nothing useful.
The failure mode is boring and predictable. You fetch page 1 in the browser, then page 2, then page 3, often through a generic data layer that treats every page as a separate cache entry, and the app starts paying for duplicated JSON parsing, duplicated object graphs, duplicated suspense boundaries, and a lot of JavaScript whose only job is compensating for the fact that the server should have assembled this view in the first place. Chrome's heap climbs, mobile Safari gets aggressive, scroll restoration breaks, and TTFB still isn't good because the first render waits for the client bundle before any pagination logic can even run.
This gets worse with infinite scroll. Teams call it modern UX, users call it "where did my place go" after a refresh, and ops calls it expensive because your API now serves a burst of tiny requests that bypass most obvious caching paths. Offset-based pagination makes the database work harder as the dataset grows, and it also creates weird duplicate and missing-row bugs once records are inserted or deleted between fetches. You've seen this one before, item 101 appears at the end of one page and the start of the next, or disappears entirely.
At steezr we've inherited a few systems like this, usually in internal dashboards and customer portals where people assumed the data volume would stay small. It never does. The fix wasn't a heroic rewrite. The fix was moving pagination decisions back to the server, switching to cursors, and letting Next.js 16 cache route segments where it actually matters.
cursors or pain
Offset pagination is fine for admin screens with a few hundred rows and no concurrency pressure. Anything else deserves cursors. I don't mean a vague base64 blob with undocumented contents either, I mean a cursor schema you can reason about six months later when somebody asks why sort order drifted under load.
A practical cursor for append-heavy tables uses two fields, a stable unique identifier and a monotonic sequence. The sequence can be a created_at microsecond timestamp if your write path guarantees enough precision, or better, a dedicated bigint generated at insert time. The id breaks ties. You sort by sequence desc, id desc, and encode both values into an opaque token.
A response shape like this works well:
1{2 "items": [3 { "id": "inv_9f3...", "sequence": 98122314, "total": 4200 },4 { "id": "inv_9f2...", "sequence": 98122313, "total": 1250 }5 ],6 "page": {7 "next": "eyJzZXEiOjk4MTIyMzEzLCJpZCI6Imludl85ZjIuLi4ifQ==",8 "prefetch": [9 "eyJzZXEiOjk4MTIyMzExLCJpZCI6Imludl85ZjAuLi4ifQ=="10 ],11 "hasMore": true12 }13}
On PostgreSQL the query is straightforward and, unlike OFFSET 20000 LIMIT 50, it keeps using the index efficiently:
1SELECT id, sequence, total, issued_at2FROM invoices3WHERE account_id = $14 AND (sequence, id) < ($2, $3)5ORDER BY sequence DESC, id DESC6LIMIT 50;
Then put the right index under it:
1CREATE INDEX CONCURRENTLY idx_invoices_account_sequence_id2ON invoices (account_id, sequence DESC, id DESC);
Opaque matters, because clients shouldn't couple themselves to your sort keys, and because you'll eventually change something. Opaque doesn't mean random. You still need deterministic encoding and signing. If a cursor can be tampered with, somebody will hand you garbage and you'll spend a Friday night debugging invalid input syntax for type bigint from a public endpoint.
Keep prefetch windows small, usually one page ahead, maybe two on very fast networks for content-heavy feeds. Anything larger starts acting like speculative overfetch, which is just a more respectable name for waste.
next.js 16 changes the shape
Next.js 16 finally makes this pattern pleasant enough that there isn't much excuse left for pushing the whole problem into the browser. React Server Components already gave us the right primitive, fetch data on the server, stream HTML and payload chunks, keep the client thin. What changed is that route-segment caching and cache controls are now good enough to structure paginated views around server-rendered segments without turning your app into a cache invalidation horror show.
The key idea is simple. Treat the initial list page as a server concern. Render the first slice in a server component, cache the segment for a short window if the data can tolerate it, and hand the client a cursor for the next slice. Navigation to the next segment stays fast because the route can be prefetched and the server already knows how to assemble it. You stop shipping pagination orchestration code to every browser session, and you get consistent HTML output for bots, slow devices, and users hitting refresh.
A page component can stay almost boring:
1// app/customers/[customerId]/invoices/page.tsx2import { headers } from 'next/headers'34export default async function InvoicesPage({ params, searchParams }) {5 const { customerId } = await params6 const { after } = await searchParams78 const h = await headers()9 const auth = h.get('authorization')1011 const res = await fetch(12 `${process.env.API_URL}/v1/customers/${customerId}/invoices?limit=50${after ? `&after=${encodeURIComponent(after)}` : ''}`,13 {14 headers: {15 authorization: auth ?? ''16 },17 next: {18 revalidate: 30,19 tags: [`customer:${customerId}:invoices`]20 }21 }22 )2324 if (!res.ok) {25 throw new Error(`Invoices fetch failed: ${res.status} ${await res.text()}`)26 }2728 const data = await res.json()29 return <InvoicesList initialPage={data} customerId={customerId} />30}
That revalidate: 30 is enough for a lot of dashboards. For busier screens you tag it and invalidate on writes with revalidateTag('customer:123:invoices') from a server action or mutation endpoint. Short-lived segment caching gives you fast repeat navigations without pretending stale financial data should hang around for ten minutes. That's the difference between useful caching and cargo cult caching.
cache headers that actually help
Most teams either under-cache or lie with cache headers. Both are bad. If your API sits behind a CDN or a gateway that respects HTTP caching, make the response explicit. Paginated resources with cursors are excellent candidates for short shared freshness plus stale-while-revalidate, especially for anonymous or org-scoped feeds where many users hit the same early pages.
A sane response header set looks like this:
1Cache-Control: public, s-maxage=30, stale-while-revalidate=1202Vary: Accept-Encoding, Authorization, X-Org-Id3ETag: "invoices:org_42:after_root:limit_50:v1739029911"4Content-Type: application/json; charset=utf-8
If the endpoint is user-specific, don't mark it public unless your cache key fully includes the auth boundary. People get this wrong constantly. One missing Vary: Authorization and you've built a data leak with excellent latency numbers.
Conditional requests are worth the trouble. If your backend can cheaply compute an ETag keyed by the page cursor and the latest relevant mutation version, clients and intermediaries can send If-None-Match, your API returns 304 Not Modified, and you avoid burning CPU serializing the same rows every few seconds. For Go services we usually wire this close to the handler, for Django we keep it explicit because magic middleware tends to hide the details you eventually need to debug.
For example, a Next.js route handler acting as a BFF can forward and preserve these semantics:
1export async function GET(req: Request) {2 const upstream = await fetch(`${process.env.API_URL}/v1/feed?${new URL(req.url).searchParams}`, {3 headers: {4 'if-none-match': req.headers.get('if-none-match') ?? ''5 }6 })78 return new Response(upstream.body, {9 status: upstream.status,10 headers: {11 'cache-control': upstream.headers.get('cache-control') ?? 'private, no-store',12 'etag': upstream.headers.get('etag') ?? '',13 'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8'14 }15 })16}
The important part is alignment. Server component fetch caching, route-segment caching, CDN behavior, and upstream API headers need to agree on freshness. If one layer says 30 seconds and another says no-store, you'll get confusing misses and a lot of Slack messages about random performance.
prefetch without overeating
Prefetch is where teams usually ruin a good server-first design. They hear "prefetch the next page" and immediately schedule three background requests on mount, one more on scroll, and another one because the router did its own prefetch. Then they wonder why memory and bandwidth still look ugly.
A small prefetch window is enough. One page ahead covers most human behavior because people read sequentially. Two pages ahead can make sense for image-light datasets on desktop broadband. Anything past that belongs in the bin unless you have hard telemetry proving users consume lists faster than the network can respond. They usually don't.
In Next.js 16, route-aware prefetching is already available through the router and Link, so the clean move is to prefetch the next cursor-bearing route segment, not to invent an ad hoc browser cache. If your URL shape is /invoices?after=opaque_cursor, then the next link can be rendered by the server and prefetched when it nears the viewport.
1import Link from 'next/link'23export function NextPageLink({ nextCursor }: { nextCursor?: string }) {4 if (!nextCursor) return null56 return (7 <Link8 prefetch9 href={`/invoices?after=${encodeURIComponent(nextCursor)}`}10 className="text-sm underline"11 >12 Next 5013 </Link>14 )15}
That does two useful things. It keeps navigation addressable, which matters for refresh, sharing, and support tickets, and it lets the framework manage prefetch lifecycles instead of pinning arbitrary JSON blobs in client state forever. Infinite scroll can still exist on top if product insists, though I prefer a visible continuation control for any screen where users care about position. Scroll-triggered auto-load is fine for social feeds. It is terrible for audit logs and invoices.
If you do keep infinite scroll, cap the in-memory window. Drop older pages from the rendered list, keep anchors for restoration, and preserve cursor history separately. Otherwise you'll rediscover the delightful RangeError: Invalid string length or a tab getting murdered by mobile Safari after twenty minutes of use.
tradeoffs and migration
This pattern has tradeoffs, and they're acceptable. Cursor pagination complicates back navigation a little, because "previous page" isn't free unless you store prior cursors or support reverse traversal. Sort order must be stable, which means product teams can't keep inventing mixed-field sorting rules without engineering paying for it. Cache invalidation still exists, nobody escaped that, and highly volatile feeds may need revalidate: 0 on the first segment with selective caching only for follow-up pages.
Even with those caveats, the migration path is easier than people assume. You don't need to rewrite every list in the app. Start with one expensive screen, usually whichever route shows up in your traces with a fat client bundle and repeated XHR churn. Add a cursor-capable endpoint alongside the old ?page=7 API. Keep the same item serializer, change the pagination envelope, add the composite index, and put the initial page behind a server component. Then expose next-page navigation as a cursorized URL. That's enough to cut bundle work, reduce browser heap, and improve refresh behavior immediately.
A phased backend contract might look like this:
1GET /v1/invoices?limit=502GET /v1/invoices?limit=50&after=eyJzZXEiOjk4MTIyMzEzLCJpZCI6Imludl85ZjIuLi4ifQ==
Keep the old endpoint alive for a while:
1GET /v1/invoices?page=7&page_size=502Deprecation: true3Sunset: Tue, 30 Jun 2026 00:00:00 GMT4Link: </v1/invoices?limit=50>; rel="successor-version"
You can run both paths side by side, measure with real traffic, then delete the client pager once error rates stay flat. That's usually a two-sprint job, not a quarter-long platform initiative.
The teams that benefit most are the ones building operational software, internal systems, customer portals, document queues, places where users care about continuity and speed more than flashy motion. That's a lot of the work we do at steezr, and the pattern keeps paying off because it aligns with how the web actually works, server-render first, cache carefully, keep the browser light, and stop making JavaScript solve problems your backend already knows how to solve.
