Appearance
Demo: Customers Table
Route: /demo/customers · Feature module: src/features/demo/customers/
What this demonstrates
A fully server-driven data table: pagination, search, filtering and sorting are all resolved server-side against a validated input shape (not by filtering a client-side array), and the entire table state lives in the URL via nuqs - making it shareable, bookmarkable, and durable across a refresh or the browser back button, unlike a typical useState-driven table.
Key files
| File | Role |
|---|---|
model/customer.ts | Customer type + mock-data generator (137 rows) |
schema/customer-query-schema.ts | Zod schema for valid table state (server-side input validation) |
params.ts | nuqs parsers mirroring the same shape (client-side URL state) |
server/router.ts | customersRouter.getMany - filter/sort/paginate |
hooks/use-customers-params.ts | Wraps useQueryStates for URL read/write |
hooks/use-customers-search.ts | Debounced search input → URL param |
hooks/use-customers.ts | The actual data-fetching query |
components/* | Table, toolbar, pagination |
How it works
One shape, two representations.
params.ts(nuqs parsers) andcustomer-query-schema.ts(Zod) describe the exact same fields -page,pageSize,search,status,sortBy,sortOrder- with matching defaults. The URL is the client-side source of truth; the Zod schema is what the server actually validates against. Keeping them in sync by hand is the tradeoff for not having a single shared runtime schema driving both nuqs and tRPC input.URL state, not component state.
useCustomersParams()wraps nuqs'suseQueryStates(customersParams)- every read is the current URL query string, every write updates it (each param usesclearOnDefault: true, so the URL stays clean instead of accumulating?page=1&sortOrder=descnoise when a value matches its default).No layout jank on refetch.
useCustomers()passesplaceholderData: keepPreviousDatatouseQuery- changing the sort column or page keeps showing the previous rows while the new page loads, instead of flashing back to a loading skeleton on every interaction.Debounced search without URL thrash.
useCustomersSearch()keeps every keystroke in local component state (so typing feels instant) and only commits to the nuqssearchparam - and resets to page 1 - after 400ms of no typing. Writing to the URL on every keystroke would spam browser history and refire the query constantly.Server-side resolution.
customersRouter.getManyfilters, sorts, and paginatesMOCK_CUSTOMERSentirely in memory - the same shape of work a realWHERE/ORDER BY/LIMITSQL query would do:tsconst SIMULATED_LATENCY_MS = 400 // filter by status, filter by search (name/email substring) // sort by the chosen column and direction // slice to the requested pageThe mock dataset itself (
MOCK_CUSTOMERS) is generated once, at module load, via a smallweightedPick()helper overMath.random()- so it's a fresh random set of 137 rows on every server restart, but stable for the lifetime of that running process (not regenerated per request).
Try it
Open /demo/customers, search, filter by status, sort a column, and change page - then copy the URL into a new tab. The table opens back up in the exact same state.
Pattern to copy
This is the reference for any real paginated/filterable table you build against Prisma - swap the in-memory filter/sort/slice of MOCK_CUSTOMERS for a real prisma.<model>.findMany({ where, orderBy, skip, take }) built from the same validated input shape; the nuqs/URL-state layer on the client doesn't change at all.
Related docs
- Frontend - State management
- Architecture - tRPC request flow