Data tables and pagination
The list convention — Paginated queries, URL-state filters and pagination with nuqs, tables, empty states. Use when building or changing any list of records, or when pagination/filtering behaves oddly.
Generated from
.claude/skills/data-tables-and-pagination/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
Every list in this project has the same shape, whether a human wrote it or
lq generate feature did: a query returning Paginated<T>, filters and page
number in the URL, a data table with a total line, and windowed pagination
under it. The reference implementation is src/features/projects.
The sameness is the point — it is what lets an upgrade codemod recognise and rewrite every list in the project.
The contract
src/lib/pagination.ts is the whole convention:
interface Paginated<T> {
items: T[];
total: number; // rows matching the FILTERS, not just this page
page: number;
pageSize: number;
}Offset pagination, deliberately. Cursors are better at scale and worse at
everything a starting product needs: jumpable page numbers, shareable URLs, a
total for "page 3 of 12". When a hot list earns cursors, only the query
changes — callers see Paginated<T> either way.
The query
One withTenant transaction: build the where once, count with it, clamp the
requested page against the count, then fetch the page:
return withTenant(ctx, async (tx) => {
const where = conditions.length ? and(...conditions) : undefined;
const [row] = await tx.select({ total: count() }).from(projects).where(where);
const total = row?.total ?? 0;
const page = clampPage(filters.page, total);
const items = await tx.select({...}).from(projects).where(where)
.orderBy(desc(projects.createdAt))
.limit(DEFAULT_PAGE_SIZE)
.offset(offsetFor(page));
return { items, total, page, pageSize: DEFAULT_PAGE_SIZE };
});Count and page share the transaction and the where — a count from a
different filter set silently renders wrong page numbers.
clampPage is why ?page=999 shows the last page instead of an empty one.
An empty page with a working "previous" button is technically correct and
reads as broken.
Filters come from the URL, validated
The list page parses searchParams with the feature's own schema, so a
hand-edited query string cannot reach a query unchecked:
page: z.coerce.number().int().min(1).catch(1).default(1),.catch(1), not an error: the page number arrives from a URL anyone can edit,
and ?page=banana deserves page 1, not an error page.
Client side: nuqs, three non-negotiable options
Filter and pagination controls write the URL with nuqs:
useQueryStates({...}, { shallow: false, startTransition, clearOnDefault: true });shallow: false— the Server Component re-readssearchParams; a shallow update changes the URL and nothing else.startTransition— keeps the current list visible while the new one loads, and gives you a pending flag.clearOnDefault— default values leave the URL, so clean states have clean URLs.
Changing any filter resets the page (page: null in the same
setFilters call). Page 3 of a search that no longer has 3 pages is an empty
screen; changing what you look at resets where you are in it.
The page composes it
<Suspense key={JSON.stringify(filters)} fallback={<ProjectListSkeleton />}>
<ProjectList ctx={ctx} filters={filters} />
</Suspense>Keyed on the filters, so changing them shows the skeleton again rather than silently holding the previous list. The list itself is a Server Component that reads directly — no client fetching, no loading state of its own.
Table, not <ul>
Lists of records grow columns — status today, owner and due date tomorrow —
and a <ul> grows sideways badly. The shape, from
src/features/projects/components/project-list.tsx:
<DataTable>
<Table>
<TableHeader>
<TableRow>
<SortableHead column="name">Name</SortableHead>
<SortableHead column="status">Status</SortableHead>
<SortableHead column="createdAt" align="right">Created</SortableHead>
<TableHead className="w-12"><span className="sr-only">Actions</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>{/* … */}</TableBody>
</Table>
</DataTable>
<TableFooterBar count={total} noun="project">
<ProjectPagination page={page} total={total} pageSize={pageSize} />
</TableFooterBar>- Link the display column to the detail page, and give the row a
RowActionsmenu as well. A row whose only affordance is a link means every edit is open → find button → go back. - Format every non-string cell through
@/lib/format. Dates gettext-right tabular-nums, because a column of values whose digits do not line up is a column you cannot compare down. - Statuses get a
StatusBadgefrom the feature's tone map — not a ternary betweendefaultandsecondary, which renders four meanings as one grey pill. - The skeleton must be the same table: same columns, same alignment, real
header text. A
<ul>shadowing a<table>reflows the page the moment data lands, which is worse than no skeleton.
Pagination renders null for a single page: page numbers for one page are
furniture.
Sorting is URL state too
SortableHead writes ?sort=&dir= and resets page, exactly as a filter
does. The column prop must be a value the feature's list schema accepts:
export const projectSort = ['name', 'status', 'createdAt'] as const;
// …
sort: z.enum(projectSort).catch('createdAt').default('createdAt'),
dir: z.enum(['asc', 'desc']).catch('desc').default('desc'),That union is the whole of what stands between a query string and an
ORDER BY, so index a record of columns by it rather than building the column
from the string. And always tie-break on the primary key — two rows sharing
a createdAt will otherwise swap places between pages, which duplicates one
row and hides another.
Filters: chips, and a way out
FilterBar takes the controls as children and the active-filter chips as a
prop. Ship the chips. Without them, someone who opens a shared URL carrying
three filters sees a short list and no explanation for why it is short, and
someone who filters down to nothing has to guess which control did it.
Empty states are two different states
A filtered-empty list ("nothing matches those filters") is a different problem
from a truly-empty account ("create your first…"). Offering "create one" to
someone who mistyped a search is the kind of small wrongness that makes
software feel careless. The empty-state component takes a filtered boolean —
compute it from the filters, not from total — and passes a different icon,
sentence and action for each. Use EmptyState from @/components/app; a
bare dashed box with three words in it reads as a bug.
Child lists are a different shape
A comment thread, an invoice's line items, an activity feed: these hang off a
detail page that has no URL of its own to hold ?page, so Paginated<T> does
not fit — and dropping the convention silently is how a ticket with five
thousand comments loads five thousand rows into a Server Component.
Use the bounded window instead:
export async function listComments(ctx: TenantContext, ticketId: string) {
return withTenant(ctx, async (tx) => {
const rows = await tx
.select({ … })
.from(comments)
.where(and(eq(comments.ticketId, ticketId), eq(comments.organizationId, ctx.organizationId)))
.orderBy(desc(comments.createdAt))
.limit(DEFAULT_CHILD_WINDOW + 1); // one extra, on purpose
return windowed(rows); // { items, hasMore }
});
}The extra row is what makes "is there more" answerable without a second count
query — a count on a child list is a round trip for a number nobody displays.
Render hasMore as a "load older" affordance, or leave it unused and simply
be honest that the thread is capped.
Search
Search today is ilike '%term%' on the display column — right for a starting
product, wrong past a few tens of thousands of rows. When a real table earns
it, move that one query to a tsvector column; the Paginated contract means
nothing else changes.
Don't hand-roll any of this for a new feature
lq generate feature emits the whole convention — query, schema, sortable
table, filters with chips, pagination, both empty states, a matching skeleton
and a <feature>-badges.tsx tone map — wired together. Generate, then edit.
Two things to check on the generated output rather than assume:
- The badge tones are a guess made from the enum value names.
paidlands onsuccessandoverdueondanger, but your domain's words are yours. money, notint, for amounts. Amoneyfield is still an integer column of minor units, but it renders as currency, right-aligns, and its input says it wants cents.amountCents:intgives you a column headed "Amount cents" showing480,000, which is how the invoicing product built on this template shipped.
Marketing and SEO
The public site — marketing.ts as the single copy declaration, which block to use where, the two landing layouts, and the SEO helpers (page metadata, canonicals, JSON-LD, sitemap). Use when editing the landing page, adding a public page, or changing anything a crawler sees.
AI patterns
How AI features work here — the provider seam, metered entry points, streaming route vs one-shot action, and the usage budget wired into billing. Use when adding any model call, or when AI spend and entitlements are involved.