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.
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.
Invoices
Invoices
Invoices
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.
<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
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.
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.
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.
If you have TanStack Query / SWR / Apollo, use their keepPreviousData equivalent. Same idea — show stale rows while the new page loads.
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.
| Number | Amount | Status |
|---|---|---|
| INV-0001 | $120 | Paid |
| INV-0002 | $45 | Open |
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.
Final composition
~150 lines including the URL hook. Pagination + sort + filter + URL, no client-side sorting trick.
| Number | Amount ↓ | Status |
|---|---|---|
| INV-0301 | $2,200 | Paid |
| INV-0302 | $1,200 | Paid |
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-sorton 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.