# List Page

> Bespoke per-module list page pattern using DiceUI DataTable + useDataTable + nuqs URL state + RelativeTimeCard for dates + module-typed Hono RPC for rows. Use this skill when adding a new top-level list page (e.g., `/widgets`, `/orders`) inside a vobase module, or when refactoring an old free-form list view into the standard data-table layout. Also use when the user says "list page", "make a table for X", "data table for module Y", "filter pills for the list", or "URL-shareable filters".

- Skill: `vobase/list-page` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vobase/list-page`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vobase/list-page/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vobase (https://skillmd.com/u/vobase)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vobase/list-page

---


# List page (bespoke DataTable)

Slice 1 of `external-cli-and-collapse-shell` retired the saved-views meta-schema and the generic `<ViewRenderer>` runtime. Lists are now per-module with bespoke `<DataTable>` pages — every page owns its columns, filters, query keys, and server endpoint. The five filter pills, three sort options, and two columns the page uses are checked into source as React; nothing is reconciled at boot, nothing is reflected through a generic table renderer.

## When to apply

- Any new top-level list route (`/contacts`, `/messaging`, `/agents`, `/schedules`, …).
- Replacing a card-list, ad-hoc grid, or `<ViewRenderer>` callsite with a structured table.

Skip when the underlying data is genuinely small + static (≤20 rows, no filters) — a plain unordered list or `<table>` with `RelativeTimeCard` is fine.

## Canonical example

`packages/template/modules/contacts/pages/index.tsx` is the reference implementation. Read that file first; this skill summarizes the load-bearing parts.

## Stack

- **DataTable + DataTableToolbar** from DiceUI (`bunx shadcn@latest add "https://diceui.com/r/data-table.json"`)
- **`useDataTable`** owns URL state via nuqs (`?page=2&filters[segments]=qualified,vip&sort=createdAt.desc`)
- **TanStack Query** keys are derived from the URL params, so URL change ⇒ query refetch
- **Module-typed Hono RPC** (`contactsClient`, `messagingClient`, …) for row fetches; **never** raw `fetch` in `src/**` (banned by `no-raw-fetch.grit`)
- **`<RelativeTimeCard date={...}>`** for any date column — auto-updating, i18n-safe, hover for full timestamp

## Server side

A list endpoint receives the URL params, validates with Zod, runs filters through Drizzle, and returns `{ rows, pageCount }`. Use `filterColumns()` from the `data-table` skill's reference if you need full server-side filtering; for simple lists, hand-roll the predicates.

## Filter rails

Hardcoded React arrays — not metadata. Slice 1's `<InboxFilterRail>` precedent: `Active | Later | Done` baked into the component, no DB lookup, no saved-view round-trip. New filter? Edit the source.

## What to avoid

- Don't introduce a generic `<ViewRenderer scope="…">` again. Pages own their tables.
- Don't reach for a YAML view definition or `defineViewable()` — both are gone.
- Don't write `new Date(value).toLocaleString()` — wrap with `<RelativeTimeCard date={value} />`.
- Don't bypass the typed RPC client; a raw `fetch('/api/…')` will fail the `check:bundle` lint and produce untyped responses.

