Cursor by default, offset when small
Completing the URL-state list view by choosing between cursor and offset pagination and wiring the paging control through nuqs.
The invoices screen now filters, sorts, and searches. One pillar is left: the one The list-view anatomy left as a stub in page.tsx, a commented <Pagination cursor={cursor} hasNext={nextCursor != null} /> waiting for this lesson. We finish it here.
Pagination looks like the most mechanical of the four pillars: a Next button, maybe a page number, you’ve clicked a thousand of them. But it hides the sharpest decision in the chapter. Picture the real table this screen sits in front of: fifty thousand invoices, and your teammates are creating new ones all day. A user is on page 3 when someone in accounting adds an invoice. What goes in the URL when that user clicks “Next,” a ?page=4 or an opaque ?cursor=…, and what does that choice cost when the data shifts under someone partway through paging it?
That question has a right answer, and getting it wrong produces a bug that is almost invisible in development and embarrassingly visible in production: the user sees the same invoice twice, or never sees one at all. So this lesson leads with the decision before the syntax, the way an experienced engineer reaches it.
One thing up front, so you know what is not new here. The database side of pagination is already built. Back in chapter 38, the lesson on cursor pagination taught the keyset model: the mandatory id tiebreaker, the opaque base64 encode and decode, the trick of fetching one extra row to learn whether a next page exists, and the composite index the query rides. Your listInvoices(...) already returns { rows, nextCursor }. That is a prerequisite, not the content of this lesson. This lesson is the URL-state side: which paging token belongs in the URL, the contract a cursor link makes to whoever you share it with, the <Pagination /> control wired to nuqs, and the cursor-versus-offset call you make per surface. By the end the four-pillar list view is complete and shareable.
Cursor or offset: the decision before the syntax
Section titled “Cursor or offset: the decision before the syntax”There are two ways to ask a database for “the next page.” You met both in chapter 38; here we line them up as a decision, because choosing between them is the durable skill. The syntax on either side is a footnote once the choice is made.
Offset is the one every tutorial reaches for, because every ORM’s first pagination example is LIMIT 20 OFFSET 40. You tell the database “skip this many rows, then give me the next page.” It supports random access: “jump to page 12” is just OFFSET 220, which is exactly why it feels natural. But it has two costs. First, it degrades linearly with depth: to serve page 500 the database walks past every row it skips, so deep pages get slower the further in you go. Second, and this is the cost that bites, it is unstable under writes. The offset counts rows, so if a row is inserted or deleted above the page you’re on, every row’s position shifts and the count points somewhere new.
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 is “give me the rows that come after this sort key.” Because it anchors to a value rather than a count, it is 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. The one thing it gives up is jumping to an arbitrary page N: there is no “page 12,” only “the rows after this one.” That is a perfect fit for Next and load-more UX, and a poor fit for a numbered pager.
So here is the verdict, stated plainly: cursor by default. Offset earns its place only when all three of these hold: the set is small and known to be bounded (a few hundred rows at most), the offset stays shallow, and the product genuinely needs “jump to page N.” Think of an admin table, an org’s settings list, or a short audit view. A production-scale list view, like the invoices screen with its fifty thousand rows, defaults to cursor every time.
The shape of the reasoning is the part worth keeping. An experienced engineer doesn’t reach for a pagination style by habit; they ask three questions, in this order: how big can this set get, is it being written to while users page through it, and does the user need random page access or just “more”? Size comes first, because it gates everything; then stability under writes, because that is where offset quietly breaks; then the UX need, because random access is the only thing offset buys you. Walk the questions and the answer falls out.
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.
Even on a small set, if 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.
One term there is worth pinning, since it carries the whole decision. A bounded set is one whose maximum size you can name up front. That ceiling is what licenses offset: a set you know stays small never pages deep enough for offset’s linear cost to bite, and it is small enough that the window for drift under writes is narrow. The deeper question of why an index scan stays cheap belongs to the database chapters; for URL state, the decision above is the part that matters.
What each paging token looks like in the URL
Section titled “What each paging token looks like in the URL”With the decision made, here is the vocabulary: what each choice actually puts in the address bar, side by side.
A cursor shows up as an opaque blob:
?cursor=eyJpZCI6NDIsImNyZWF0ZWRBdCI6IjIwMjYtMDQtMTUifQ==Three things about that blob are deliberate engineering decisions, not accidents.
First, the user sees noise and the server decodes it. The opaqueness is the point. A readable cursor invites users to hand-construct one, and the moment someone pastes ?cursor=page999 you’re fielding a bug report. An opaque blob also lets you change the internal encoding later, adding a field or switching the format, without breaking links people shared months ago, because nobody was depending on its shape.
Second, what’s inside the blob is exactly what chapter 38 built: the sort key plus the id tiebreaker. Decode that base64 and you get something like { createdAt: '2026-04-15', id: 42 }, the position of the last row on the current page. You don’t re-derive any of that here; the decoding already lives in the parseCursor helper from that lesson. The URL side only needs to know the blob is a position, and that it travels in the cursor parameter.
Third, and this is a contract: if the blob is malformed, hand-edited, or encoded for a shape the server no longer understands, decoding falls back to the first page silently. It never throws an error at the user. A pagination cursor that someone copied wrong should land them at the top of the list, not on an error screen.
An offset page, by contrast, shows up readable:
?page=5Two deliberate calls hide in that small URL. We pick page-based ?page=N over raw ?offset=80 for the offset case, because it is the user-facing pattern: Linear, Stripe, and Slack all use ?page=N for their small lists. It is human-readable, and a user can edit it sensibly. Note also what is missing: there is no page size in the URL. The pageSize lives in the parser as a fixed default, not in the address bar, unless the product genuinely lets the user pick their page size, in which case it becomes ?pageSize=50, clamped to a maximum in the parser. That clamp is not optional: an unclamped pageSize lets anyone type ?pageSize=10000000 and ask your database for ten million rows in one query. Bound it at the boundary.
Here are the two parsers next to each other. The cursor parser already exists in your searchParams.ts from the first lesson, so you reuse it rather than write it again. 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. The cursor is just a string parameter with no default, so an absent cursor is the canonical first page. The server decodes the blob; the URL only carries it. It is already in your searchParams.ts from the first lesson, so you reuse it rather than redefine 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 is stripped from the URL. Add this parser only on a surface where you’ve decided 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”Here is the failure that settles the tie. It is worth seeing concretely before we wire anything, because once you have, you won’t reach for ?page=N on a live table by reflex again.
Set the scene. 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. Sorted newest-first, it lands at the very top of the list, so every existing row just shifted down by one position.
Now the user clicks “page 2,” expecting rows 21 through 40 from OFFSET 20 LIMIT 20. But “skip 20 rows” no longer means what it meant a moment ago. After the insert, the first 20 rows are the new invoice plus the old rows 1 through 19. The row that was number 20, the last one they saw on page 1, got pushed to position 21, so it is the first row OFFSET 20 returns. The user sees the last invoice from page 1 repeated at the top of page 2, a duplicate, and the invoice that should have been row 21 is pushed past the window and seen by nobody. The deeper they page, the more this drift accumulates.
Watch it happen one step at a time.
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 The contrast in that last step is the whole argument. Cursor pagination doesn’t ask “skip 20 rows”; it asks “give me the rows after this sort key,” and a sort key is a stable anchor. Insert a row above the anchor and the anchor doesn’t budge, so there is no duplicate and no skip. That structural immunity is the entire reason cursor is the default for any list that is being written to, which in a real SaaS is every list.
Wiring the <Pagination /> control
Section titled “Wiring the <Pagination /> control”With the decision settled, we build the control and fill the stub the first lesson left behind. The shape mirrors the <SearchInput /> from Typed input, committed URL: the server computes everything and hands it down as props, the client owns only the write, and the file is a Client Component.
Start with the server, because it barely changes. The page already parses searchParams and calls listInvoices, which already returns { rows, nextCursor }. All this lesson adds is passing those values down to <Pagination />.
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 parses the cursor from the URL, runs the query, and passes three things to the control: 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 at all. The control reads none of those from the URL itself; it receives them as props, exactly like every other control in this chapter.
Now the control, which is smaller than you might expect. Pagination writes are infrequent, a click rather than a keystroke, so none of the rhythm machinery from the search lesson applies here: 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. The page stays the single read-source; this is the 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 here, just as it was for 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 the fetch-one-extra-row result from chapter 38: 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.
A few things are worth saying around that code.
hasNext comes from the server’s extra row, not from a count(*). Chapter 38’s trick was to ask for one more row than the page size; if it comes back, there is a next page. That is cheap at any depth, which matters precisely because cursor pagination is what you reach for on deep, large lists.
You’ll notice there is a “Next” and a “First page,” but no “Previous.” That is a deliberate cut, worth understanding rather than just accepting. A true “Previous” button needs a backward cursor, a ?before=… token the server computes by querying in the opposite direction, which roughly doubles the server-side pagination logic. Plenty of real list views never need it: a Next-plus-load-more flow, or the closely related infinite-scroll style, only ever moves forward. So the course default is forward-only with a “First page” escape hatch, and ?before=… is the reach you add when the product asks for back-paging, not before. Build the simple thing, and earn the complex one.
The accessibility here is not decoration. The control is a real <nav aria-label="Pagination"> so a screen-reader user can find it by landmark, the buttons are real <button type="button"> elements rather than a <div> dressed up with an onClick, and they are disabled at the ends so there is nothing to click into a wall. Chapter 27 covers the accessibility primitives in depth; the contract here is simpler: real semantics, no fakes.
When the decision does land on offset, on that small, bounded admin table, the control changes shape but not principle. Instead of a single Next, you render numbered page links, and each one calls a setPage(n) setter built on the pageParser from earlier. The rule that carries over is the same one: those page links use the setter, which uses replace, the same in-list-navigation policy as every filter and sort control from Filter shapes and sort encoding. Paging is in-view navigation, not a new destination, so it never belongs in the back-button history as a separate stop.
The reset invariant, from the pagination side
Section titled “The reset invariant, from the pagination side”You already own the rule this section is about. Filter shapes and sort encoding named the reset invariant and baked it into the sort control; Typed input, committed URL reapplied it to search. What is new here is not the rule but that the cursor is finally the thing being reset, so you get to see it from the other side.
Recall what 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. Change the sort from newest-first to highest-total, and that anchor now points into a completely differently ordered list, where the rows “after” it are arbitrary. Change a filter and the anchor may not even be in the result set anymore. The breakage is the same class as the inserted-row bug from earlier, repeated and skipped rows, except this time you would have caused it yourself by carrying a stale cursor across a change that invalidated it.
So the cursor moves under exactly two rules. It advances only through Next (or, when you build it, Previous). And it resets to null inside the same setter call as any filter, sort, or search change, never as a separate step. This is why, two lessons ago, the sort control wrote setQuery({ sort: next, cursor: null }) and the search input bundled cursor: null into its write. Back then the cursor was a black box those writes protected; now you can see it is the position those resets keep honest. The fix is structural: nuqs does not clear the cursor for you, so you bake cursor: null into the write, exactly as the earlier controls already did.
Let’s make sure that is solid, since it is the invariant the whole chapter rests on.
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”This last point separates understanding the system from filing “the link is broken,” and it completes the share-and-refresh contract this chapter has carried since the first lesson.
That contract promised that you could paste the URL to a coworker and they would see the same view. For filters, sort, and search that is literally true, because those parameters fully describe the query. For a cursor, it needs one refinement. Paste a cursor link and your coworker lands at the rows after the encoded sort key, in the current data, not a frozen photograph of what was on your screen. If invoices were inserted above the anchor since you copied the link, they will see rows you didn’t; if some were deleted, they will see fewer. That is correct behavior for the cursor contract: the cursor means “the rows after this position,” and it delivers exactly that against live data. It is not a bug.
Keep this sentence; it is the one to reach for when a teammate files “the pagination link is broken”:
And notice that offset is strictly worse here, not better. A ?page=5 link has the same non-snapshot property, since it also reads against live data, plus the drift problem from earlier, so two coworkers opening the same ?page=5 at different moments can see overlapping or skipped rows. Sharing doesn’t rescue offset; it compounds its weakness.
If a product ever genuinely needs a frozen view, “the invoices list exactly as of this instant,” for a regulatory export or a point-in-time audit, that is a different feature entirely: a stored snapshot or an export, not a URL parameter. It is out of scope here; just know the boundary exists, so you don’t try to make a cursor do a snapshot’s job.
This same “stale token” theme has one more tail worth naming: cursor versioning against sort. A cursor encoded for -createdAt, if it is somehow decoded against a -total sort, yields wrong results, the same mismatch the reset invariant exists to prevent. You have two defenses, and they layer. The primary one is structural and already shipped: the reset invariant clears the cursor whenever the sort changes, so a sort-mismatched cursor should not normally exist in the first place. The backstop is defense in depth: the cursor blob can carry a tag identifying the sort it was encoded for (that tag goes in at encode time, back in chapter 38’s helper), and parseCursor falls back to the first page on a mismatch. The reset invariant prevents the problem, and versioning catches a hand-edited or long-stale shared link that slips through.
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”One last decision is small but easy to overlook, and most newcomers never think to make it: whether to show a total count at all.
“Showing 21–40 of 50,000” is friendly. It also costs you an extra count(*) query on every page load, and on a large, busy table that count can dominate the page’s latency: the rows come back fast on their index, and then the page waits on a count of fifty thousand. So the call splits cleanly along the same line as the pagination style:
- Cursor-paginated views, the large-set default. Skip the total. Show “Next” and “Load more” (and “First page”); the fetch-one-extra-row trick already tells you whether a next page exists without counting anything. This is the default for the invoices list: no count query, no latency tax.
- Offset-paginated small bounded sets. Compute the total. The set is small, so the count is cheap, and the random-access UX positively wants it: “page 3 of 7” is meaningless without the 7.
There is a scale caveat that belongs to the database chapters, not here: on a genuinely huge table, even when you do want a count, count(*) itself can need optimizing through estimated counts and similar tricks. That is a chapter 39 concern. For URL state, the rule is enough: cursor lists skip the total, small offset lists show it.
That closes the four-pillar list view. Filter, sort, search, and paginate now all live in the URL, all survive a refresh and a share, and the pagination token follows the same reset invariant as the rest. The page reads and queries on the server; the controls write through setters on the client; one parser module defines the shape for both sides. The next chapter adds the soft-delete and archive controls on top of exactly this shape, and the chapter-end project wires it all into a live CRUD surface.
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.