launch-quickly
Patterns

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-reads searchParams; 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 RowActions menu 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 get text-right tabular-nums, because a column of values whose digits do not line up is a column you cannot compare down.
  • Statuses get a StatusBadge from the feature's tone map — not a ternary between default and secondary, 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 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. paid lands on success and overdue on danger, but your domain's words are yours.
  • money, not int, for amounts. A money field is still an integer column of minor units, but it renders as currency, right-aligns, and its input says it wants cents. amountCents:int gives you a column headed "Amount cents" showing 480,000, which is how the invoicing product built on this template shipped.

On this page