Cursor by default, offset when small
Choosing cursor or offset pagination for a list view and wiring the paging control through nuqs.
The invoices screen now filters, sorts, and searches.
One pillar is left: the <Pagination cursor={cursor} hasNext={nextCursor != null} /> that List-view anatomy left commented out in page.tsx.
Pagination looks mechanical, a Next button and maybe a page number, but it hides a real decision.
The table holds fifty thousand invoices, and teammates add new ones all day.
A user is on page 3 when accounting inserts an invoice.
When that user clicks “Next,” what goes in the URL: ?page=4 or an opaque ?cursor=…?
Get it wrong and the user sees an invoice twice or skips one entirely.
The database side already exists: chapter 38 built the keyset model, and your listInvoices(...) returns { rows, nextCursor }.
This lesson is the URL-state side: which paging token belongs in the URL, what a shared cursor link promises, and the cursor-versus-offset call you make per surface.
Choosing cursor or offset pagination
Section titled “Choosing cursor or offset pagination”There are two ways to ask a database for “the next page,” both from chapter 38.
Offset is the one every ORM reaches for first, with LIMIT 20 OFFSET 40: skip this many rows, then return the next page.
It supports random access, so “jump to page 12” is just OFFSET 220.
But it has two costs.
It degrades linearly with depth: to serve page 500 the database walks past every row it skips.
And it is unstable under writes, because the offset counts rows, so inserting or deleting a row above the current page shifts every position.
Cursor (keyset) pages by value, not by count.
The token is an opaque blob holding the last row’s sort key plus its id tiebreaker, and the next query asks for the rows after that sort key.
Anchoring to a value rather than a count makes it stable under inserts and deletes: an invoice added at the top doesn’t move the anchor.
It stays an index scan at any depth, so page 500 is as fast as page 2.
It gives up one thing: there is no “page 12,” only “the rows after this one” — a fit for Next and load-more UX, a poor fit for a numbered pager.
The verdict: cursor by default. Offset earns its place only when all three hold: the set is small and bounded (a few hundred rows at most), the offset stays shallow, and the product needs “jump to page N.” Think of an admin table or an org’s settings list. A production-scale list like the invoices screen, with its fifty thousand rows, defaults to cursor every time.
Large sets are written to constantly and paged deep. Cursor stays stable under inserts and stays an index scan at any depth, so it is the production default for a list like invoices.
A small, bounded set with a genuine need for random page access. The offset stays shallow, so its linear cost never shows, and ?page=N reads naturally in the URL.
On a small set where the user only needs Next or Load-more, there is no reason to take on offset’s drift under writes. Cursor is the simpler, safer pick.
A bounded set is one whose maximum size you can name up front: one org’s API keys, one user’s saved addresses, not an open-ended list that grows with usage. That ceiling is what licenses offset: a set you know stays small never pages deep enough for the linear cost to bite, and the window for drift under writes is narrow.
What cursor and offset put in the URL
Section titled “What cursor and offset put in the URL”A cursor shows up as an opaque blob:
?cursor=eyJpZCI6NDIsImNyZWF0ZWRBdCI6IjIwMjYtMDQtMTUifQ==That opaqueness is three deliberate choices.
First, the user sees noise and the server decodes it.
A readable cursor invites users to hand-construct one, and the moment someone pastes ?cursor=page999 you’re fielding a bug report.
Opaqueness also lets you change the internal encoding later, adding a field or switching format, without breaking links people shared months ago.
Second, the blob holds what chapter 38 built: the sort key plus the id tiebreaker.
Decode that base64 and you get the position of the last row on the current page, like { createdAt: '2026-04-15', id: 42 }.
The decoding already lives in the parseCursor helper from that lesson; the URL only needs to know a position travels in the cursor parameter.
Third, if the blob is malformed, hand-edited, or built for a shape the server no longer understands, decoding falls back to the first page silently. A cursor someone copied wrong should land them at the top of the list, never on an error screen.
An offset page, by contrast, shows up readable:
?page=5We pick page-based ?page=N over raw ?offset=80 because it is the user-facing pattern: Linear, Stripe, and Slack all use ?page=N, and a user can edit it sensibly.
Note what is missing: no page size.
The pageSize lives in the parser as a fixed default, unless the product lets the user pick it, in which case it becomes ?pageSize=50, clamped to a maximum in the parser.
Without that clamp, anyone can type ?pageSize=10000000 and ask your database for ten million rows in one query.
You reuse the cursor parser from searchParams.ts; the page parser is the conditional one, added only on a surface where you’ve decided offset is the right call.
export const cursorParser = parseAsString;Opaque, stable under writes, no random access. A string parameter with no default, so an absent cursor is the first page. The server decodes the blob; the URL only carries it. It already exists in your searchParams.ts from the first lesson, so you reuse it.
export const pageParser = parseAsInteger.withDefault(1);Readable, supports random access, shifts under inserts. A 1-based integer defaulting to 1, so the first page drops out of the URL. Add it only on a surface where offset is the right call; it does not belong on the invoices list.
Why a row inserted mid-pagination breaks offset
Section titled “Why a row inserted mid-pagination breaks offset”A user opens the invoices list, sorted newest-first.
Page 1 is rows 1 through 20, served by OFFSET 0 LIMIT 20.
While they read it, a teammate in accounting creates a new invoice.
Newest-first, it lands at the top, so every existing row shifts down one.
The user clicks “page 2,” expecting rows 21 through 40 from OFFSET 20 LIMIT 20.
But “skip 20 rows” no longer counts the same rows.
The first 20 are now the new invoice plus old rows 1 through 19, so the row that was 20, the last one they saw on page 1, has slid to position 21 and is the first row OFFSET 20 returns.
They see it again atop page 2, a duplicate, while the invoice that should have been row 21 is pushed past the window and seen by nobody, a skip.
The deeper they page, the more the drift accumulates.
OFFSET 0 LIMIT 20 page 1 — skip 0 rows OFFSET 0 LIMIT 20 page 1 — skip 0 rows OFFSET 20 LIMIT 20 page 2 — skip 20 rows WHERE (createdAt, id) < cursorOf(T) page 2 — by value Cursor pagination never says “skip 20 rows”; it says “give me the rows after this sort key.” Insert a row above the anchor and the anchor doesn’t move, so there is no duplicate and no skip.
Building the <Pagination /> control
Section titled “Building the <Pagination /> control”The control mirrors <SearchInput /> from Typed input, committed URL: the server computes everything and passes it as props, the client owns only the write, and the file is a Client Component.
The server barely changes.
The page already parses searchParams and calls listInvoices, which returns { rows, nextCursor }.
const { status, sort, q, cursor } = await searchParamsCache.parse(props.searchParams);const { rows, nextCursor } = await listInvoices({ status, sort, q, cursor });
return ( <main> {/* filter / sort / search controls */} <InvoiceTable rows={rows} /> <Pagination cursor={cursor} nextCursor={nextCursor} hasNext={nextCursor != null} /> </main>);The page stays the single read-source.
It passes the control three values: the current cursor, so the control knows whether we are past the first page; the server-computed nextCursor, the token to advance to; and hasNext, whether a next page exists.
Pagination writes are infrequent, a click rather than a keystroke, so none of the search lesson’s rhythm machinery applies: no deferred value, no debounce, just one load-bearing option and two buttons.
'use client';
import { useQueryState } from 'nuqs';
import { cursorParser } from '../searchParams';
type PaginationProps = { cursor: string | null; nextCursor: string | null; hasNext: boolean;};
export const Pagination = ({ cursor, nextCursor, hasNext }: PaginationProps) => { const [, setCursor] = useQueryState('cursor', cursorParser.withOptions({ shallow: false }));
return ( <nav aria-label="Pagination" className="flex items-center justify-between"> <button type="button" disabled={cursor == null} onClick={() => setCursor(null)}> First page </button> <button type="button" disabled={!hasNext} onClick={() => setCursor(nextCursor)}> Next </button> </nav> );};The control is a Client Component, and its three values arrive as server-computed props: cursor, nextCursor, and hasNext.
Same value-as-prop shape as StatusFilter, SortControl, and SearchInput.
'use client';
import { useQueryState } from 'nuqs';
import { cursorParser } from '../searchParams';
type PaginationProps = { cursor: string | null; nextCursor: string | null; hasNext: boolean;};
export const Pagination = ({ cursor, nextCursor, hasNext }: PaginationProps) => { const [, setCursor] = useQueryState('cursor', cursorParser.withOptions({ shallow: false }));
return ( <nav aria-label="Pagination" className="flex items-center justify-between"> <button type="button" disabled={cursor == null} onClick={() => setCursor(null)}> First page </button> <button type="button" disabled={!hasNext} onClick={() => setCursor(nextCursor)}> Next </button> </nav> );};Take only the setter from useQueryState, since the current cursor already arrives as a prop.
shallow: false is load-bearing, as in the search input: without it the URL write never notifies the server, so the list never re-queries and the page never changes.
'use client';
import { useQueryState } from 'nuqs';
import { cursorParser } from '../searchParams';
type PaginationProps = { cursor: string | null; nextCursor: string | null; hasNext: boolean;};
export const Pagination = ({ cursor, nextCursor, hasNext }: PaginationProps) => { const [, setCursor] = useQueryState('cursor', cursorParser.withOptions({ shallow: false }));
return ( <nav aria-label="Pagination" className="flex items-center justify-between"> <button type="button" disabled={cursor == null} onClick={() => setCursor(null)}> First page </button> <button type="button" disabled={!hasNext} onClick={() => setCursor(nextCursor)}> Next </button> </nav> );};Clicking Next advances to the server-provided nextCursor.
disabled={!hasNext} rides chapter 38’s fetch-one-extra-row trick: the server fetched one row beyond the page to learn whether a next page exists, with no count query needed.
'use client';
import { useQueryState } from 'nuqs';
import { cursorParser } from '../searchParams';
type PaginationProps = { cursor: string | null; nextCursor: string | null; hasNext: boolean;};
export const Pagination = ({ cursor, nextCursor, hasNext }: PaginationProps) => { const [, setCursor] = useQueryState('cursor', cursorParser.withOptions({ shallow: false }));
return ( <nav aria-label="Pagination" className="flex items-center justify-between"> <button type="button" disabled={cursor == null} onClick={() => setCursor(null)}> First page </button> <button type="button" disabled={!hasNext} onClick={() => setCursor(nextCursor)}> Next </button> </nav> );};Clearing the cursor strips the parameter, since it has no default, returning to the canonical empty-URL first page.
Never router.push('/invoices'): that stacks a history entry and re-renders the whole segment.
The nuqs setter uses replace by default, the right move for in-list navigation.
There is a “Next” and a “First page,” but no “Previous.”
A true “Previous” needs a backward cursor, a ?before=… token the server computes by querying in the opposite direction, which roughly doubles the pagination logic.
Many list views never need it: a Next-plus-load-more flow, or the related infinite scroll, only moves forward.
So the default is forward-only with a “First page” escape hatch; add ?before=… when the product asks for back-paging.
The accessibility is a real contract: a <nav aria-label="Pagination"> landmark a screen-reader user can jump to, real <button type="button"> elements rather than a <div> with an onClick, and disabled at the ends so there is nothing to click into a wall.
When the decision lands on offset, on a small, bounded admin table, the control changes shape but not principle.
Instead of a single Next, you render numbered page links, each calling a setPage(n) setter built on the pageParser from earlier.
Those links use the setter, which uses replace, the same in-list-navigation policy as every filter and sort control from Filter shapes and sort.
Clearing the cursor when sort, filter, or search changes
Section titled “Clearing the cursor when sort, filter, or search changes”You have applied the reset invariant to the sort control and the search input; the cursor is what it resets.
A cursor encodes a position in the current ordered, filtered, searched list.
The blob holds { createdAt: '2026-04-15', id: 42 }, but “the rows after April 15th, id 42” only means something relative to a specific ordering and filter.
Re-sort from newest-first to highest-total and the anchor points into a differently ordered list, where the rows “after” it are arbitrary.
Change a filter and the anchor may not be in the result set at all.
Carrying a stale cursor across a change that invalidated it reproduces the repeated-and-skipped-rows bug, except now you cause it.
So the cursor moves under two rules.
It advances only through Next or Previous.
And it resets to null inside the same setter call as any filter, sort, or search change, never as a separate step.
nuqs will not clear it for you, which is why the sort control wrote setQuery({ sort: next, cursor: null }) and the search input bundled cursor: null into its write.
A user is on page 3 of the invoices list (so the URL carries a cursor) and clicks a column header to re-sort by highest total. Which setter call keeps the list correct?
setQuery({ sort: '-total' });setSort('-total');setCursor(null); // a second, separate callsetQuery({ sort: '-total', cursor: null });setCursor(null);-createdAt position against a -total ordering — arbitrary rows. Split the reset into a second call and the URL briefly carries the stale cursor against the new sort, firing one wrong query before the reset lands. Clearing the cursor without touching the sort does nothing useful. The reset invariant is a single atomic write: the sort changes and the cursor goes to null together.A shared cursor URL is a position, not a snapshot
Section titled “A shared cursor URL is a position, not a snapshot”The chapter’s share-and-refresh contract promises a coworker who opens your URL sees your view. Filters, sort, and search hold exactly, since those parameters fully describe the query. A cursor adds one refinement: your coworker lands at the rows after the encoded sort key in the current data, not the exact rows on your screen when you copied the link. Invoices inserted above the anchor since then show up for them and not you; deleted ones leave fewer. That is the contract working, not a bug.
Offset is strictly worse for sharing: a ?page=5 link has the same non-snapshot property plus the earlier drift, so two coworkers opening it at different moments can see overlapping or skipped rows.
A genuinely frozen view, “the invoices list exactly as of this instant” for a regulatory export or audit, is a different feature: a stored snapshot , not a URL parameter.
One stale-token risk remains: a cursor encoded for -createdAt but decoded against a -total sort returns arbitrary rows.
The reset invariant clears the cursor on every sort change, so a mismatch should never exist; as a backstop, the cursor blob can carry a version tag naming the sort it was encoded for (added at encode time, in chapter 38’s helper), and parseCursor falls back to the first page when the tag doesn’t match.
When to show “21–40 of 50,000,” and when to skip it
Section titled “When to show “21–40 of 50,000,” and when to skip it”“Showing 21–40 of 50,000” is friendly, but it costs an extra count(*) on every page load, and on a large, busy table that count can dominate latency: the rows return fast on their index while the page waits on a count of fifty thousand.
The call splits along the same line as the pagination style:
- Cursor-paginated views, the large-set default. Skip the total. The fetch-one-extra-row trick already tells you whether a next page exists without counting anything, so the invoices list pays no count query.
- Offset-paginated small bounded sets. Compute the total. The set is small, so the count is cheap, and the random-access UX needs it: “page 3 of 7” is meaningless without the 7.
On a genuinely huge table, even a wanted count(*) may need estimated counts, a database-chapter concern.
Going deeper
Section titled “Going deeper”The URL-state layer this chapter standardized on — parser reference and the useQueryState options, including shallow.
A real product's journey from offset to opaque Base64 cursors with a next_cursor token — the same contract, at scale.
Optional database-side depth on why keyset beats offset, for the SQL mechanics behind chapter 38.