Home/ Cookbook/ Tables/ Server-paginated

Build a server-paginated table — page in the URL, never lost

By the end of this recipe you'll have a paginated data table whose page number, page size, sort, and filters live in the URL, with server-side sort + filter and a "Page 4 of 12" pager at the bottom.

Tables 3 primitives · 1 block · 1 pattern ~25 min React · @corelithzw/react

Overview

A table with finite, paged data: the server returns one page at a time, with the total count. The URL holds ?page=4&perPage=50&sort=createdAt:desc&status=paid so any state is shareable.

The whole point of pagination is honesty — "there are 4,231 rows; here are 50; you're on page 4." Infinite scroll lies about that ("loading…" forever). When users need to bookmark page 7, send a link to a coworker, or come back tomorrow to the same view, pagination is the only fair option.

Pagination is for finite data, infinite scroll is for feeds. A list of all your products is finite; the feed of incoming notifications is a feed. Don't mix the metaphors — your users will get lost.

Skip this recipe if your dataset is under 200 rows (load all of it, sort/filter on the client) or if your data is fundamentally a feed (use the virtualised list recipe).

What you'll build

Page 1, page 4, and the per-page selector open.

Huchu

Invoices

NumberAmountSt
INV-0001$120P
INV-0002$45O
INV-0003$1,200P
INV-0004$85O
INV-0005$330P
Page 1 of 12
01 Page 1 of 12 — URL: ?page=1
Huchu

Invoices

NumberAmountSt
INV-0301$520P
INV-0302$45O
INV-0303$2,200P
INV-0304$85O
Page 4 of 12
02 Page 4 — shareable URL
Huchu

Invoices

Per page102550100
NumberAmountSt
INV-0001$120P
INV-0002$45O
INV-0003$1,200P
03 Per-page picker — URL: ?perPage=25

Required pieces

A table, a pager, a per-page select, and a URL state hook.

@corelithzw/react exports used here: Table, Pagination, Select, DataToolbar, Skeleton, useUrlState.

Roadmap note: a packaged <Table> primitive is planned for @corelithzw/react — for now use a plain semantic <table> element with the design tokens. useUrlState is also on the roadmap; the hand-rolled version below works today.

Step-by-step build

01

URL state — the source of truth for page, sort, filter

Don't keep page/sort/filter in useState. Keep them in the URL. When the user hits Back, they land on the previous page (the actual previous page, not the previous app state). When they share the URL, the recipient lands on the same view.

Code-only step

Parse + validate per-page against an allowed set. If the URL says ?perPage=999 someone is being clever — fall back to the default, don't trust user input.

Step 1 · useTableQuery@corelithzw/react
02

Fetch — query keyed by the whole TableQuery

Every change to query triggers a refetch. Use the query object itself as the cache key. Show a skeleton on initial load, keep the previous rows on subsequent loads — flicker-free page changes are non-negotiable.

Code-only step

If you have TanStack Query / SWR / Apollo, use their keepPreviousData equivalent. Same idea — show stale rows while the new page loads.

Step 2 · Fetch
03

Render — table + pager, sort headers wired to URL

Sort headers are buttons that toggle the URL. The pager shows total pages computed from total / perPage. Both update the URL — the URL change triggers the refetch.

NumberAmountStatus
INV-0001$120Paid
INV-0002$45Open
Per page: 25 Page 1 of 12 ‹ ›
Step 3 · Render
04

Loading + error states — never blank, never deceptive

On initial load: skeleton rows. On subsequent: keep showing the previous rows with aria-busy. On error: show the error and a Retry button — don't pretend you've got nothing.

Step 4 · States

Final composition

~150 lines including the URL hook. Pagination + sort + filter + URL, no client-side sorting trick.

?page=4&perPage=25&sort=amount:desc&status=paid
NumberAmount ↓Status
INV-0301$2,200Paid
INV-0302$1,200Paid
25 per pagePage 4 of 12 · 287 total‹ 1 … 4 … 12 ›
InvoicesTable.tsx@corelithzw/react

Variations

Three forks. URL still drives the state.

Cursor-based pagination

For very large or live-changing data, page numbers lie. Switch to a cursor (opaque server-issued token) per page. URL stores the cursor; "Page 4 of 12" becomes "Page 4 of many".

// URL: ?after=abc123
const { rows, nextCursor } = await
  fetchInvoices({ after: q.after });
// pager: Prev | Next only,
// no jump-to-page

"Load more" instead of pager

Same server query, but append instead of replace. Use when "browsing" beats "jumping to a known page". Don't combine with sort changes — too easy to confuse.

const onMore = () => setQ({
  page: q.page + 1, append: true
});
// inside the fetch:
setData(d => ({
  total: r.total,
  rows: [...(d?.rows ?? []), ...r.rows]
}));

Client-side filter on top of server page

If your server filter is missing a field, you can post-filter the current page client-side — but warn the user that "showing 3 of 25 on this page" is not "3 of total".

const visible = rows.filter(r =>
  r.notes?.includes(clientQuery)
);
// add a banner:
<Alert>Filtering page 4 only.</Alert>

Accessibility

Tables get a lot of free a11y if you use real <table> elements.

  • Real <table> <th> <td>. Screen readers navigate by row/column. CSS-grid "tables" lose that — every row is an unrelated line of text.
  • aria-sort on each sortable <th>. Values: "ascending" | "descending" | "none". Only the active sort column has a non-"none" value.
  • Sort headers are <button>s. A click handler on a <th> isn't focusable. Wrap the label in a button.
  • aria-busy="true" during fetches. Lets AT pause announcements until the new rows arrive.
  • "Page X of Y" in aria-live="polite". After paging, the announcement happens automatically — confirms the action.
  • The pager is <nav aria-label="Pagination">. Screen reader users jump to it via the rotor.