Drizzle ORM + Nuqs
The Drizzle ORM server-side table with the full table state persisted in the URL via nuqs: shareable, bookmarkable, refresh-proof.
The Drizzle ORM guide turns every filter, sort, and page into real SQL. This variant keeps that exact backend and moves the table state into the URL with nuqs, so any filtered/sorted/paginated view is shareable, bookmarkable, and survives reload. The Drizzle query-building code (buildWhere / buildOrderBy / computeFacets) is unchanged: only the state source flips from local useState to useQueryStates.
Read the Drizzle ORM guide for the SQL layer and the Server-Side Nuqs Table for the URL layer in depth; this page only shows how they combine.
Live demo (mocked)
Section titled “Live demo (mocked)”The demo runs this guide’s Drizzle code against a mocked query builder in your browser and shows the Generated SQL for the current view. Filter or sort, then reload the page: the view is restored from the URL. The toolbar Category and Brand facets read server-computed counts and narrow to the values still present in the results.
Product Name | Category | Brand | Price | Stock | Rating | In Stock | Release Date |
|---|---|---|---|---|---|---|---|
Loading...
[]1"use client"2
3/**4 * Drizzle ORM Server-Side Table + nuqs URL state — live, mocked demo5 *6 * The Drizzle ORM guide's demo, with every state slice (page, sorting,7 * search, filters, column visibility) stored in the URL via nuqs — so any8 * view is shareable, bookmarkable, and survives reload. The query-building9 * code (`buildWhere`, `buildOrderBy`, `computeFacets`) is identical to the10 * Drizzle guide — same operators, same dispatch, same SQL — only the state11 * source changes from local `useState` to `useQueryStates`.12 *13 * The operators here are a ~80-line mock of drizzle-orm's API that compiles14 * each condition to BOTH a row predicate (executed against an in-memory15 * array in your browser) and SQL text with bound params (shown live in the16 * Generated SQL card).17 *18 * To go real: install drizzle-orm, replace the "MOCK DRIZZLE" section with19 * `import { and, or, eq, ilike, ... } from "drizzle-orm"` and your pgTable20 * schema, and move fetchProducts behind an API route — the builder code21 * stays the same. Full walkthrough: the Drizzle ORM guide in the docs.22 *23 * Prerequisites: npm install @tanstack/react-query nuqs24 *25 * nuqs needs an adapter for your router; this example wraps its own26 * NuqsAdapter (React SPA). Swap it for your framework's adapter:27 * - Next.js App Router: import { NuqsAdapter } from "nuqs/adapters/next/app"28 * - Next.js Pages Router: import { NuqsAdapter } from "nuqs/adapters/next/pages"29 * - React SPA (Vite, ..): import { NuqsAdapter } from "nuqs/adapters/react"30 * See https://nuqs.dev/docs/adapters.31 */32
33import { useCallback, useMemo, useState } from "react"34import {35 keepPreviousData,36 QueryClient,37 QueryClientProvider,38 useQuery,39} from "@tanstack/react-query"40import { NuqsAdapter } from "nuqs/adapters/react"41import {42 parseAsInteger,43 parseAsJson,44 parseAsString,45 useQueryStates,46} from "nuqs"47import type {48 ColumnFiltersState,49 PaginationState,50 SortingState,51 Updater,52 VisibilityState,53} from "@tanstack/react-table"54import { DataTableRoot } from "@/components/niko-table/core/data-table-root"55import { DataTable } from "@/components/niko-table/core/data-table"56import {57 DataTableHeader,58 DataTableBody,59 DataTableEmptyBody,60 DataTableSkeleton,61} from "@/components/niko-table/core/data-table-structure"62import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"63import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"64import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"65import { DataTableColumnFacetedFilterMenu } from "@/components/niko-table/components/data-table-column-faceted-filter"66import { DataTableFacetedFilter } from "@/components/niko-table/components/data-table-faceted-filter"67import { DataTableColumnSliderFilterMenu } from "@/components/niko-table/components/data-table-column-slider-filter-options"68import { DataTableColumnDateFilterMenu } from "@/components/niko-table/components/data-table-column-date-filter-options"69import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"70import {71 DataTableEmptyIcon,72 DataTableEmptyMessage,73 DataTableEmptyFilteredMessage,74 DataTableEmptyTitle,75 DataTableEmptyDescription,76} from "@/components/niko-table/components/data-table-empty-state"77import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"78import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"79import { DataTableSortMenu } from "@/components/niko-table/components/data-table-sort-menu"80import { DataTableFilterMenu } from "@/components/niko-table/components/data-table-filter-menu"81import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"82import { daysAgo } from "@/components/niko-table/lib/format"83import {84 FILTER_OPERATORS,85 FILTER_VARIANTS,86} from "@/components/niko-table/lib/constants"87import { processFiltersForLogic } from "@/components/niko-table/lib/data-table"88import {89 normalizeFiltersFromUrl,90 serializeFiltersForUrl,91} from "@/components/niko-table/filters/table-filter-menu"92import { useDebounce } from "@/components/niko-table/hooks/use-debounce"93import type {94 DataTableColumnDef,95 ExtendedColumnFilter,96} from "@/components/niko-table/types"97import { Badge } from "@/components/ui/badge"98import { Button } from "@/components/ui/button"99import {100 Card,101 CardAction,102 CardContent,103 CardDescription,104 CardHeader,105 CardTitle,106} from "@/components/ui/card"107import { AlertCircle, Database, Loader2, SearchX } from "lucide-react"108
109/* -------------------------------------------------------------------------110 * 1. The wire contract — identical to the Server-Side Table example111 * ---------------------------------------------------------------------- */112
113type Product = {114 id: string115 name: string116 category: string117 brand: string118 price: number119 stock: number120 rating: number121 inStock: boolean122 releaseDate: Date123}124
125type ProductQuery = {126 page: number127 pageSize: number128 sorting: SortingState129 search: string | object130 columnFilters: ColumnFiltersState131}132
133type ProductQueryResult = {134 data: Product[]135 total: number136 facets: {137 select: Record<string, Array<{ value: string; count: number }>>138 range: Record<string, [number, number]>139 }140 /** Demo-only: the SQL a real Postgres would receive for this query. */141 debug: { sql: string; params: unknown[] }142}143
144/* -------------------------------------------------------------------------145 * 2. MOCK DRIZZLE — delete this section in a real app146 *147 * A miniature stand-in for drizzle-orm's condition builders with the same148 * call signatures. Each operator compiles to SQL text + bound params AND a149 * predicate so the query can run against the in-memory rows below. With150 * real Drizzle you'd write instead:151 *152 * import { and, or, not, eq, ne, ilike, notIlike, gt, gte, lt, lte,153 * between, inArray, notInArray, isNull, asc, desc } from "drizzle-orm"154 * ---------------------------------------------------------------------- */155
156/** A queryable column: SQL name + accessor into the row object. */157type Col = { name: string; key: keyof Product }158
159/** A compiled condition: SQL text (with `?` placeholders) + predicate. */160type Cond = {161 sql: string162 params: unknown[]163 test: (row: Product) => boolean164}165
166type Order = { sql: string; cmp: (a: Product, b: Product) => number }167
168const num = (v: unknown): number =>169 v instanceof Date ? v.getTime() : Number(v)170const lower = (v: unknown): string => String(v).toLowerCase()171
172const eq = (c: Col, v: unknown): Cond => ({173 sql: `${c.name} = ?`,174 params: [v],175 test: r => lower(r[c.key]) === lower(v),176})177const ne = (c: Col, v: unknown): Cond => ({178 sql: `${c.name} <> ?`,179 params: [v],180 test: r => lower(r[c.key]) !== lower(v),181})182const ilike = (c: Col, v: string): Cond => ({183 sql: `${c.name} ILIKE ?`,184 params: [v],185 test: r => lower(r[c.key]).includes(lower(v).replaceAll("%", "")),186})187const notIlike = (c: Col, v: string): Cond => ({188 sql: `${c.name} NOT ILIKE ?`,189 params: [v],190 test: r => !lower(r[c.key]).includes(lower(v).replaceAll("%", "")),191})192const gt = (c: Col, v: unknown): Cond => ({193 sql: `${c.name} > ?`,194 params: [v],195 test: r => num(r[c.key]) > num(v),196})197const gte = (c: Col, v: unknown): Cond => ({198 sql: `${c.name} >= ?`,199 params: [v],200 test: r => num(r[c.key]) >= num(v),201})202const lt = (c: Col, v: unknown): Cond => ({203 sql: `${c.name} < ?`,204 params: [v],205 test: r => num(r[c.key]) < num(v),206})207const lte = (c: Col, v: unknown): Cond => ({208 sql: `${c.name} <= ?`,209 params: [v],210 test: r => num(r[c.key]) <= num(v),211})212const between = (c: Col, lo: unknown, hi: unknown): Cond => ({213 sql: `${c.name} BETWEEN ? AND ?`,214 params: [lo, hi],215 test: r => num(r[c.key]) >= num(lo) && num(r[c.key]) <= num(hi),216})217const inArray = (c: Col, values: unknown[]): Cond => ({218 sql: `${c.name} IN (${values.map(() => "?").join(", ")})`,219 params: values,220 test: r => values.some(v => lower(r[c.key]) === lower(v)),221})222const notInArray = (c: Col, values: unknown[]): Cond => ({223 sql: `${c.name} NOT IN (${values.map(() => "?").join(", ")})`,224 params: values,225 test: r => !values.some(v => lower(r[c.key]) === lower(v)),226})227const isNull = (c: Col): Cond => ({228 sql: `${c.name} IS NULL`,229 params: [],230 test: r => r[c.key] === null || r[c.key] === undefined,231})232const not = (cond: Cond): Cond => ({233 sql: `NOT (${cond.sql})`,234 params: cond.params,235 test: r => !cond.test(r),236})237const and = (...conds: (Cond | undefined)[]): Cond | undefined =>238 combine(conds, "AND")239const or = (...conds: (Cond | undefined)[]): Cond | undefined =>240 combine(conds, "OR")241function combine(242 conds: (Cond | undefined)[],243 op: "AND" | "OR",244): Cond | undefined {245 const defined = conds.filter((c): c is Cond => c !== undefined)246 if (defined.length === 0) return undefined247 if (defined.length === 1) return defined[0]248 return {249 sql: `(${defined.map(c => c.sql).join(` ${op} `)})`,250 params: defined.flatMap(c => c.params),251 test:252 op === "AND"253 ? r => defined.every(c => c.test(r))254 : r => defined.some(c => c.test(r)),255 }256}257const asc = (c: Col): Order => ({258 sql: `${c.name} ASC`,259 cmp: (a, b) => (num(a[c.key]) < num(b[c.key]) ? -1 : 1),260})261const desc = (c: Col): Order => ({262 sql: `${c.name} DESC`,263 cmp: (a, b) => (num(a[c.key]) > num(b[c.key]) ? -1 : 1),264})265
266// Text columns compare as strings, not numbers267const strCmp =268 (c: Col, dir: 1 | -1) =>269 (a: Product, b: Product): number =>270 dir * String(a[c.key]).localeCompare(String(b[c.key]))271
272/** Render `?` placeholders as $1..$n for display. */273function numberParams(sql: string): string {274 let i = 0275 return sql.replace(/\?/g, () => `$${++i}`)276}277
278/* -------------------------------------------------------------------------279 * 3. Schema + column mapping — mirrors db/schema.ts in the guide280 * ---------------------------------------------------------------------- */281
282const products = {283 id: { name: "id", key: "id" },284 name: { name: "name", key: "name" },285 category: { name: "category", key: "category" },286 brand: { name: "brand", key: "brand" },287 price: { name: "price", key: "price" },288 stock: { name: "stock", key: "stock" },289 rating: { name: "rating", key: "rating" },290 inStock: { name: "in_stock", key: "inStock" },291 releaseDate: { name: "release_date", key: "releaseDate" },292} satisfies Record<string, Col>293
294/** UI column id → Drizzle column. Unknown ids fall out safely. */295const columnMap: Record<string, Col> = {296 name: products.name,297 category: products.category,298 brand: products.brand,299 price: products.price,300 stock: products.stock,301 rating: products.rating,302 inStock: products.inStock,303 releaseDate: products.releaseDate,304}305
306const TEXT_COLUMNS = new Set(["name", "category", "brand", "id"])307
308/** Columns the global search input scans. */309const searchableColumns = [products.name, products.category, products.brand]310
311/* -------------------------------------------------------------------------312 * 4. Filters → WHERE / ORDER BY — this code is the guide's code, verbatim313 * ---------------------------------------------------------------------- */314
315type MenuFilter = ExtendedColumnFilter<Product>316
317/**318 * Comparison operators bind their value straight into the query. A non-scalar319 * value (an accidentally-nested object/array) would otherwise reach the320 * builder as-is — with a real driver that's a malformed bound parameter, not321 * a graceful no-op — so guard to the shapes a column value can actually be.322 */323function isComparableScalar(value: unknown): boolean {324 return (325 value === null ||326 value instanceof Date ||327 typeof value === "string" ||328 typeof value === "number" ||329 typeof value === "boolean"330 )331}332
333/** One advanced-filter-menu rule → SQL condition. */334function menuFilterToSql(filter: MenuFilter): Cond | undefined {335 const column = columnMap[filter.id]336 if (!column) return undefined337 const value = filter.value338
339 const needsValue =340 filter.operator !== FILTER_OPERATORS.EMPTY &&341 filter.operator !== FILTER_OPERATORS.NOT_EMPTY342 if (needsValue && (value === undefined || value === null || value === ""))343 return undefined344
345 switch (filter.operator) {346 case FILTER_OPERATORS.ILIKE:347 return ilike(column, `%${String(value)}%`)348 case FILTER_OPERATORS.NOT_ILIKE:349 return notIlike(column, `%${String(value)}%`)350 case FILTER_OPERATORS.EQ:351 return isComparableScalar(value) ? eq(column, value) : undefined352 case FILTER_OPERATORS.NEQ:353 return isComparableScalar(value) ? ne(column, value) : undefined354 case FILTER_OPERATORS.GT:355 return isComparableScalar(value) ? gt(column, value) : undefined356 case FILTER_OPERATORS.GTE:357 return isComparableScalar(value) ? gte(column, value) : undefined358 case FILTER_OPERATORS.LT:359 return isComparableScalar(value) ? lt(column, value) : undefined360 case FILTER_OPERATORS.LTE:361 return isComparableScalar(value) ? lte(column, value) : undefined362 case FILTER_OPERATORS.IN:363 return Array.isArray(value) ? inArray(column, value) : undefined364 case FILTER_OPERATORS.NOT_IN:365 return Array.isArray(value) ? notInArray(column, value) : undefined366 case FILTER_OPERATORS.EMPTY:367 return or(isNull(column), eq(column, ""))368 case FILTER_OPERATORS.NOT_EMPTY:369 return not(or(isNull(column), eq(column, ""))!)370 default:371 return undefined372 }373}374
375/** One columnFilters entry (widget value OR menu filter) → SQL condition. */376function columnFilterToSql(id: string, value: unknown): Cond | undefined {377 // advanced filter menu entries carry an `operator`378 if (379 value &&380 typeof value === "object" &&381 !Array.isArray(value) &&382 "operator" in value383 ) {384 return menuFilterToSql(value as MenuFilter)385 }386
387 const column = columnMap[id]388 if (!column || value == null || value === "") return undefined389
390 // single date filter: bare ms timestamp → match the calendar day391 if (typeof value === "number" && id === "releaseDate") {392 const day = new Date(value)393 day.setHours(0, 0, 0, 0)394 const next = new Date(day.getTime() + 24 * 60 * 60 * 1000)395 return and(gte(column, day), lt(column, next))396 }397
398 // [min, max] tuple from slider / date-range filters (null = open-ended)399 if (400 Array.isArray(value) &&401 value.length === 2 &&402 !value.every(v => typeof v === "string")403 ) {404 const [min, max] = value as [unknown, unknown]405 const lo = min == null ? undefined : toColumnValue(id, min)406 const hi = max == null ? undefined : toColumnValue(id, max)407 if (lo != null && hi != null) return between(column, lo, hi)408 if (lo != null) return gte(column, lo)409 if (hi != null) return lte(column, hi)410 return undefined411 }412
413 // string[] from faceted multi-select414 if (Array.isArray(value)) {415 return value.length > 0 ? inArray(column, value) : undefined416 }417
418 if (typeof value === "boolean") return eq(column, value)419
420 return ilike(column, `%${String(value)}%`)421}422
423/** Timestamp columns receive Date objects; everything else passes through. */424function toColumnValue(id: string, value: unknown): unknown {425 return id === "releaseDate" ? new Date(Number(value)) : value426}427
428/**429 * All conditions for a query. `excludeColumnId` powers facet computation:430 * a column's facet is computed under every filter EXCEPT its own.431 */432function buildWhere(433 query: ProductQuery,434 excludeColumnId?: string,435): Cond | undefined {436 const conditions: (Cond | undefined)[] = []437
438 // global text search across the searchable columns439 if (typeof query.search === "string" && query.search) {440 const term = query.search441 conditions.push(or(...searchableColumns.map(c => ilike(c, `%${term}%`))))442 }443
444 // OR / MIXED logic from the advanced filter menu (rides in `search`)445 if (446 typeof query.search === "object" &&447 query.search &&448 "filters" in query.search449 ) {450 const filterObj = query.search as { filters: MenuFilter[] }451 const orConditions = (filterObj.filters ?? [])452 .filter(f => f.id !== excludeColumnId)453 .map(menuFilterToSql)454 conditions.push(or(...orConditions))455 }456
457 // AND logic: every columnFilters entry must match458 for (const filter of query.columnFilters) {459 if (filter.id === excludeColumnId) continue460 conditions.push(columnFilterToSql(filter.id, filter.value))461 }462
463 return and(...conditions)464}465
466/** query.sorting → ORDER BY. */467function buildOrderBy(query: ProductQuery): Order[] {468 return query.sorting469 .filter(s => columnMap[s.id])470 .map(s => {471 const column = columnMap[s.id]472 const order = s.desc ? desc(column) : asc(column)473 // mock-only: text columns need string comparison for the predicate474 return TEXT_COLUMNS.has(s.id)475 ? { ...order, cmp: strCmp(column, s.desc ? -1 : 1) }476 : order477 })478}479
480/* -------------------------------------------------------------------------481 * 5. MOCK DATABASE + API — with real Drizzle this is an API route482 * executing db.select().from(products).where(...).orderBy(...)483 * ---------------------------------------------------------------------- */484
485const baseProducts: Product[] = [486 {487 id: "1",488 name: "iPhone 15 Pro",489 category: "electronics",490 brand: "apple",491 price: 999,492 stock: 45,493 rating: 5,494 inStock: true,495 releaseDate: daysAgo(5),496 },497 {498 id: "2",499 name: "Galaxy S24 Ultra",500 category: "electronics",501 brand: "samsung",502 price: 1199,503 stock: 32,504 rating: 5,505 inStock: true,506 releaseDate: daysAgo(10),507 },508 {509 id: "3",510 name: "Air Jordan 1",511 category: "sports",512 brand: "nike",513 price: 170,514 stock: 8,515 rating: 4,516 inStock: true,517 releaseDate: daysAgo(25),518 },519 {520 id: "4",521 name: "Ultraboost 23",522 category: "sports",523 brand: "adidas",524 price: 190,525 stock: 15,526 rating: 4,527 inStock: true,528 releaseDate: daysAgo(50),529 },530 {531 id: "5",532 name: "PlayStation 5",533 category: "electronics",534 brand: "sony",535 price: 499,536 stock: 0,537 rating: 5,538 inStock: false,539 releaseDate: daysAgo(365),540 },541 {542 id: "6",543 name: "OLED C3 TV",544 category: "electronics",545 brand: "lg",546 price: 1499,547 stock: 12,548 rating: 5,549 inStock: true,550 releaseDate: daysAgo(90),551 },552 {553 id: "7",554 name: "XPS 15 Laptop",555 category: "electronics",556 brand: "dell",557 price: 1899,558 stock: 20,559 rating: 4,560 inStock: true,561 releaseDate: daysAgo(120),562 },563 {564 id: "8",565 name: "Spectre x360",566 category: "electronics",567 brand: "hp",568 price: 1599,569 stock: 18,570 rating: 4,571 inStock: true,572 releaseDate: daysAgo(15),573 },574 {575 id: "9",576 name: "MacBook Pro 16",577 category: "electronics",578 brand: "apple",579 price: 2499,580 stock: 25,581 rating: 5,582 inStock: true,583 releaseDate: daysAgo(30),584 },585 {586 id: "10",587 name: "Galaxy Book3",588 category: "electronics",589 brand: "samsung",590 price: 1399,591 stock: 14,592 rating: 4,593 inStock: true,594 releaseDate: daysAgo(180),595 },596 {597 id: "11",598 name: "Running Shorts",599 category: "clothing",600 brand: "nike",601 price: 45,602 stock: 120,603 rating: 3,604 inStock: true,605 releaseDate: daysAgo(60),606 },607 {608 id: "12",609 name: "Training Jacket",610 category: "clothing",611 brand: "adidas",612 price: 85,613 stock: 65,614 rating: 4,615 inStock: true,616 releaseDate: daysAgo(45),617 },618 {619 id: "13",620 name: "Garden Tools Set",621 category: "home-garden",622 brand: "hp",623 price: 120,624 stock: 30,625 rating: 4,626 inStock: true,627 releaseDate: daysAgo(75),628 },629 {630 id: "14",631 name: "Programming Book",632 category: "books",633 brand: "dell",634 price: 60,635 stock: 50,636 rating: 5,637 inStock: true,638 releaseDate: daysAgo(200),639 },640 {641 id: "15",642 name: "Wireless Mouse",643 category: "electronics",644 brand: "lg",645 price: 35,646 stock: 200,647 rating: 3,648 inStock: true,649 releaseDate: daysAgo(150),650 },651]652
653// The "database" — 15 base products expanded to 500 rows.654const productRows: Product[] = Array.from({ length: 500 }, (_, i) => {655 const base = baseProducts[i % baseProducts.length]656 const variation = Math.floor(i / baseProducts.length)657 return {658 ...base,659 id: `${base.id}-${variation}`,660 name: variation > 0 ? `${base.name} (${variation + 1})` : base.name,661 price: base.price + variation * 10,662 stock: Math.max(0, base.stock - variation * 5),663 inStock: base.stock - variation * 5 > 0,664 releaseDate: new Date(665 base.releaseDate.getTime() - variation * 24 * 60 * 60 * 1000,666 ),667 }668})669
670const FACET_SELECT_COLUMNS = ["category", "brand"] as const671const FACET_RANGE_COLUMNS = ["price"] as const672
673/** Grouped counts / min-max with the column's own filter excluded. */674function computeFacets(query: ProductQuery): ProductQueryResult["facets"] {675 const select: ProductQueryResult["facets"]["select"] = {}676 const range: ProductQueryResult["facets"]["range"] = {}677
678 for (const col of FACET_SELECT_COLUMNS) {679 const where = buildWhere(query, col)680 const rows = where ? productRows.filter(where.test) : productRows681 const counts = new Map<string, number>()682 for (const row of rows) {683 const v = String(row[col])684 counts.set(v, (counts.get(v) ?? 0) + 1)685 }686 select[col] = [...counts.entries()]687 .map(([value, count]) => ({ value, count }))688 .sort((a, b) => a.value.localeCompare(b.value))689 }690 for (const col of FACET_RANGE_COLUMNS) {691 const where = buildWhere(query, col)692 const rows = where ? productRows.filter(where.test) : productRows693 if (rows.length === 0) continue694 const values = rows.map(r => Number(r[col]))695 range[col] = [Math.min(...values), Math.max(...values)]696 }697
698 return { select, range }699}700
701/**702 * The fake API route. With real Drizzle this is:703 *704 * const [data, [{ total }], facets] = await Promise.all([705 * db.select().from(products).where(where)706 * .orderBy(...buildOrderBy(query))707 * .limit(query.pageSize).offset(query.page * query.pageSize),708 * db.select({ total: count() }).from(products).where(where),709 * computeFacets(query),710 * ])711 */712function fetchProducts(713 query: ProductQuery,714 delay = 400,715): Promise<ProductQueryResult> {716 return new Promise(resolve => {717 setTimeout(() => {718 const where = buildWhere(query)719 const orderBy = buildOrderBy(query)720
721 let rows = where ? productRows.filter(where.test) : [...productRows]722 if (orderBy.length > 0) {723 rows = [...rows].sort((a, b) => {724 for (const order of orderBy) {725 const result = order.cmp(a, b)726 if (result !== 0) return result727 }728 return 0729 })730 }731
732 const total = rows.length733 const offset = query.page * query.pageSize734 const data = rows.slice(offset, offset + query.pageSize)735
736 // the statement a real Postgres would receive737 const sql = numberParams(738 [739 "SELECT * FROM products",740 where ? `WHERE ${where.sql}` : "",741 orderBy.length > 0742 ? `ORDER BY ${orderBy.map(o => o.sql).join(", ")}`743 : "",744 `LIMIT ${query.pageSize} OFFSET ${offset}`,745 ]746 .filter(Boolean)747 .join("\n"),748 )749
750 resolve({751 data,752 total,753 facets: computeFacets(query),754 debug: { sql, params: where?.params ?? [] },755 })756 }, delay)757 })758}759
760/* -------------------------------------------------------------------------761 * 6. Columns — same as the Server-Side Table example762 * ---------------------------------------------------------------------- */763
764const categoryOptions = [765 { label: "Electronics", value: "electronics" },766 { label: "Clothing", value: "clothing" },767 { label: "Home & Garden", value: "home-garden" },768 { label: "Sports", value: "sports" },769 { label: "Books", value: "books" },770]771
772const brandOptions = [773 { label: "Apple", value: "apple" },774 { label: "Samsung", value: "samsung" },775 { label: "Nike", value: "nike" },776 { label: "Adidas", value: "adidas" },777 { label: "Sony", value: "sony" },778 { label: "LG", value: "lg" },779 { label: "Dell", value: "dell" },780 { label: "HP", value: "hp" },781]782
783type ProductFacets = ProductQueryResult["facets"]784
785type FacetOption = { label: string; value: string; count?: number }786
787/** Merge server facet counts onto a static option list (count 0 when absent). */788function mergeCounts(789 staticOpts: FacetOption[],790 facet: Array<{ value: string; count: number }> | undefined,791): FacetOption[] {792 if (!facet) return staticOpts793 const m = new Map(facet.map(f => [f.value, f.count]))794 return staticOpts.map(opt => ({ ...opt, count: m.get(opt.value) ?? 0 }))795}796
797/** Selected values for a column, tolerating the menu/faceted `{ value }` shape. */798function selectedFacetValues(799 columnFilters: ColumnFiltersState,800 columnId: string,801): Set<string> {802 const entry = columnFilters.find(f => f.id === columnId)803 if (!entry) return new Set()804 let raw: unknown = entry.value805 if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {806 raw = (raw as { value: unknown }).value807 }808 if (raw == null || raw === "") return new Set()809 const values = Array.isArray(raw) ? raw : [raw]810 return new Set(values.filter(v => v != null && v !== "").map(String))811}812
813/**814 * Toolbar facets carry server counts and must narrow to values still present815 * in the results — like the header funnels do. The client only holds the816 * current page, so narrowing happens here from the server counts: drop count-0817 * options, but keep any currently-selected value so it stays visible and818 * removable.819 */820function narrowFacetOptions(821 opts: FacetOption[],822 columnFilters: ColumnFiltersState,823 columnId: string,824): FacetOption[] {825 const selected = selectedFacetValues(columnFilters, columnId)826 return opts.filter(o => o.count !== 0 || selected.has(o.value))827}828
829function buildColumns(facets?: ProductFacets): DataTableColumnDef<Product>[] {830 const categoryOpts = mergeCounts(categoryOptions, facets?.select.category)831 const brandOpts = mergeCounts(brandOptions, facets?.select.brand)832 const priceRange = facets?.range.price833
834 return [835 {836 accessorKey: "name",837 header: () => (838 <DataTableColumnHeader>839 <DataTableColumnTitle />840 <DataTableColumnSortMenu />841 </DataTableColumnHeader>842 ),843 meta: {844 label: "Product Name",845 variant: FILTER_VARIANTS.TEXT,846 },847 enableColumnFilter: true,848 },849 {850 accessorKey: "category",851 header: () => (852 <DataTableColumnHeader>853 <DataTableColumnTitle />854 <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />855 <DataTableColumnFacetedFilterMenu options={categoryOpts} />856 </DataTableColumnHeader>857 ),858 meta: {859 label: "Category",860 variant: FILTER_VARIANTS.SELECT,861 options: categoryOptions,862 autoOptions: false,863 },864 cell: ({ row }) => {865 const category = row.getValue("category") as string866 const option = categoryOptions.find(opt => opt.value === category)867 return <span>{option?.label || category}</span>868 },869 enableColumnFilter: true,870 },871 {872 accessorKey: "brand",873 header: () => (874 <DataTableColumnHeader>875 <DataTableColumnTitle />876 <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />877 <DataTableColumnFacetedFilterMenu options={brandOpts} />878 </DataTableColumnHeader>879 ),880 meta: {881 label: "Brand",882 variant: FILTER_VARIANTS.SELECT,883 options: brandOptions,884 autoOptions: false,885 },886 enableColumnFilter: true,887 },888 {889 accessorKey: "price",890 header: () => (891 <DataTableColumnHeader>892 <DataTableColumnTitle />893 <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />894 <DataTableColumnSliderFilterMenu range={priceRange} />895 </DataTableColumnHeader>896 ),897 meta: {898 label: "Price",899 unit: "$",900 variant: FILTER_VARIANTS.NUMBER,901 },902 cell: ({ row }) => {903 const price = parseFloat(row.getValue("price"))904 return <div className="font-medium">${price.toFixed(2)}</div>905 },906 enableColumnFilter: true,907 },908 {909 accessorKey: "stock",910 header: () => (911 <DataTableColumnHeader>912 <DataTableColumnTitle />913 <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />914 </DataTableColumnHeader>915 ),916 meta: {917 label: "Stock",918 variant: FILTER_VARIANTS.NUMBER,919 },920 enableColumnFilter: true,921 },922 {923 accessorKey: "rating",924 header: () => (925 <DataTableColumnHeader>926 <DataTableColumnTitle />927 <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />928 </DataTableColumnHeader>929 ),930 meta: {931 label: "Rating",932 variant: FILTER_VARIANTS.NUMBER,933 },934 cell: ({ row }) => {935 const rating = Number(row.getValue("rating"))936 return (937 <div className="flex items-center gap-1">938 <span>{rating}</span>939 <span aria-hidden="true">★</span>940 </div>941 )942 },943 enableColumnFilter: true,944 },945 {946 accessorKey: "inStock",947 header: () => (948 <DataTableColumnHeader>949 <DataTableColumnTitle />950 <DataTableColumnSortMenu />951 <DataTableColumnFacetedFilterMenu />952 </DataTableColumnHeader>953 ),954 meta: {955 label: "In Stock",956 variant: FILTER_VARIANTS.BOOLEAN,957 },958 cell: ({ row }) => {959 const inStock = Boolean(row.getValue("inStock"))960 return (961 <Badge variant={inStock ? "default" : "secondary"}>962 {inStock ? "Yes" : "No"}963 </Badge>964 )965 },966 enableColumnFilter: true,967 },968 {969 accessorKey: "releaseDate",970 header: () => (971 <DataTableColumnHeader>972 <DataTableColumnTitle />973 <DataTableColumnSortMenu />974 <DataTableColumnDateFilterMenu />975 </DataTableColumnHeader>976 ),977 meta: {978 label: "Release Date",979 variant: FILTER_VARIANTS.DATE,980 },981 cell: ({ row }) => {982 const date = row.getValue("releaseDate") as Date983 return <span>{date.toLocaleDateString()}</span>984 },985 enableColumnFilter: true,986 },987 ]988}989
990/* -------------------------------------------------------------------------991 * 7. The table — same wiring as the Server-Side Table example992 * ---------------------------------------------------------------------- */993
994/** True when a columnFilters entry value came from the advanced filter menu. */995function isMenuFilterValue(996 value: unknown,997): value is ExtendedColumnFilter<Product> {998 return (999 !!value &&1000 typeof value === "object" &&1001 !Array.isArray(value) &&1002 "operator" in value1003 )1004}1005
1006/* -------------------------------------------------------------------------1007 * URL state (nuqs)1008 *1009 * One parser per state slice. The parser keys double as the URL param names,1010 * e.g. ?page=2&perPage=20&sort=[{"id":"price","desc":true}]. Identical to the1011 * server-side-nuqs example — the only difference in this file is the Drizzle1012 * data layer.1013 * ---------------------------------------------------------------------- */1014
1015/** OR/MIXED filter payload from the advanced filter menu. */1016type GlobalFilterObject = {1017 filters: ExtendedColumnFilter<Product>[]1018 joinOperator: string1019}1020
1021const tableStateParsers = {1022 page: parseAsInteger.withDefault(0),1023 perPage: parseAsInteger.withDefault(10),1024 sort: parseAsJson<SortingState>(value => value as SortingState).withDefault(1025 [],1026 ),1027 // Mixed-shape columnFilters (widget values + menu filter objects); filterIds1028 // are stripped on write and regenerated on read to keep URLs short1029 filters: parseAsJson<ColumnFiltersState>(1030 value => value as ColumnFiltersState,1031 ).withDefault([]),1032 search: parseAsString.withDefault(""),1033 // OR/MIXED advanced filters — only ever an object. No default: nuqs yields1034 // `null` when the param is absent1035 global: parseAsJson<GlobalFilterObject | null>(value => {1036 if (value && typeof value === "object" && "filters" in value) {1037 return value as GlobalFilterObject1038 }1039 return null1040 }),1041 cols: parseAsJson<VisibilityState>(1042 value => value as VisibilityState,1043 ).withDefault({}),1044}1045
1046/** Strip filterIds from menu-authored entries to keep URLs short. */1047function serializeColumnFiltersForUrl(1048 filters: ColumnFiltersState,1049): ColumnFiltersState {1050 return filters.map(f => {1051 if (isMenuFilterValue(f.value)) {1052 const [serialized] = serializeFiltersForUrl([f.value])1053 return { id: f.id, value: serialized }1054 }1055 return f1056 })1057}1058
1059function DrizzleNuqsTableContent() {1060 const [urlParams, setUrlParams] = useQueryStates(tableStateParsers, {1061 history: "replace",1062 scroll: false,1063 shallow: true,1064 })1065
1066 // URL → TanStack table state1067 const pagination = useMemo<PaginationState>(1068 () => ({ pageIndex: urlParams.page, pageSize: urlParams.perPage }),1069 [urlParams.page, urlParams.perPage],1070 )1071 const sorting = urlParams.sort1072 const columnFilters = urlParams.filters1073 const columnVisibility = urlParams.cols1074 // string search and the OR/MIXED filter object share the globalFilter slot1075 const globalFilter: string | object = urlParams.global ?? urlParams.search1076
1077 // Debounce search + columnFilters as ONE snapshot, not separately. A single1078 // advanced-menu action can write both URL params at once; debouncing them1079 // on their own clocks would let the undebounced field reach the request1080 // slightly ahead of the other, transiently mixing a new filter with a1081 // stale one (and the reverse on clear). Sorting/pagination stay undebounced.1082 const debouncedInput = useDebounce(1083 useMemo(1084 () => ({ search: globalFilter, columnFilters }),1085 [globalFilter, columnFilters],1086 ),1087 300,1088 )1089
1090 const {1091 data: queryData,1092 isLoading,1093 error: queryError,1094 isFetching,1095 isPlaceholderData,1096 refetch,1097 } = useQuery({1098 queryKey: [1099 "drizzle-products",1100 pagination.pageIndex,1101 pagination.pageSize,1102 sorting,1103 debouncedInput.search,1104 debouncedInput.columnFilters,1105 ],1106 queryFn: () =>1107 fetchProducts({1108 page: pagination.pageIndex,1109 pageSize: pagination.pageSize,1110 sorting,1111 search: debouncedInput.search,1112 columnFilters: debouncedInput.columnFilters,1113 }),1114 placeholderData: keepPreviousData,1115 })1116
1117 const data = queryData?.data ?? []1118 const totalCount = queryData?.total ?? 01119 const pageCount =1120 totalCount > 0 ? Math.ceil(totalCount / pagination.pageSize) : 11121
1122 const columns = useMemo(1123 () => buildColumns(queryData?.facets),1124 [queryData?.facets],1125 )1126
1127 // Toolbar facet options: server counts merged in, then narrowed to values1128 // still present in the results (count-0 dropped, current selection pinned).1129 const facets = queryData?.facets1130 const categoryFacetOptions = useMemo(1131 () =>1132 narrowFacetOptions(1133 mergeCounts(categoryOptions, facets?.select.category),1134 columnFilters,1135 "category",1136 ),1137 [facets?.select.category, columnFilters],1138 )1139 const brandFacetOptions = useMemo(1140 () =>1141 narrowFacetOptions(1142 mergeCounts(brandOptions, facets?.select.brand),1143 columnFilters,1144 "brand",1145 ),1146 [facets?.select.brand, columnFilters],1147 )1148
1149 const error =1150 queryError instanceof Error1151 ? queryError.message1152 : queryError1153 ? "Failed to fetch data"1154 : null1155
1156 // TanStack table state → URL. Every filter/sort change resets to the first1157 // page — the old page index may not exist in the new result set.1158 const handlePaginationChange = useCallback(1159 (updater: Updater<PaginationState>) => {1160 const next = typeof updater === "function" ? updater(pagination) : updater1161 void setUrlParams({ page: next.pageIndex, perPage: next.pageSize })1162 },1163 [pagination, setUrlParams],1164 )1165
1166 const handleSortingChange = useCallback(1167 (updater: Updater<SortingState>) => {1168 const next = typeof updater === "function" ? updater(sorting) : updater1169 void setUrlParams({ sort: next.length > 0 ? next : null, page: 0 })1170 },1171 [sorting, setUrlParams],1172 )1173
1174 const handleColumnFiltersChange = useCallback(1175 (updater: Updater<ColumnFiltersState>) => {1176 const next =1177 typeof updater === "function" ? updater(columnFilters) : updater1178 void setUrlParams({1179 filters: next.length > 0 ? serializeColumnFiltersForUrl(next) : null,1180 page: 0,1181 })1182 },1183 [columnFilters, setUrlParams],1184 )1185
1186 const handleColumnVisibilityChange = useCallback(1187 (updater: Updater<VisibilityState>) => {1188 const next =1189 typeof updater === "function" ? updater(columnVisibility) : updater1190 void setUrlParams({ cols: Object.keys(next).length > 0 ? next : null })1191 },1192 [columnVisibility, setUrlParams],1193 )1194
1195 const handleGlobalFilterChange = useCallback(1196 (value: string | object) => {1197 // Only search strings arrive here; OR/MIXED objects are written by1198 // handleMenuFiltersChange. The search input emits "" on mount/clear —1199 // don't let that wipe an active OR-filter object1200 if (typeof value !== "string") return1201 if (value === "" && urlParams.global) return1202 void setUrlParams({ search: value || null, page: 0 })1203 },1204 [urlParams.global, setUrlParams],1205 )1206
1207 const menuFilters = useMemo(() => {1208 if (urlParams.global) {1209 return normalizeFiltersFromUrl(urlParams.global.filters ?? [])1210 }1211 return normalizeFiltersFromUrl(1212 columnFilters.map(cf => cf.value).filter(isMenuFilterValue),1213 )1214 }, [urlParams.global, columnFilters])1215
1216 const handleMenuFiltersChange = useCallback(1217 (filters: ExtendedColumnFilter<Product>[] | null) => {1218 const next = filters ?? []1219 // Column-widget filters (faceted, slider, date) are preserved; only the1220 // menu-owned entries are rewritten1221 const widgetFilters = columnFilters.filter(1222 f => !isMenuFilterValue(f.value),1223 )1224 if (next.length === 0) {1225 void setUrlParams({1226 filters:1227 widgetFilters.length > 01228 ? serializeColumnFiltersForUrl(widgetFilters)1229 : null,1230 global: null,1231 page: 0,1232 })1233 return1234 }1235 const result = processFiltersForLogic(next)1236 if (result.shouldUseGlobalFilter) {1237 void setUrlParams({1238 filters:1239 widgetFilters.length > 01240 ? serializeColumnFiltersForUrl(widgetFilters)1241 : null,1242 global: {1243 filters: serializeFiltersForUrl(1244 result.processedFilters,1245 ) as ExtendedColumnFilter<Product>[],1246 joinOperator: result.joinOperator,1247 },1248 page: 0,1249 })1250 } else {1251 void setUrlParams({1252 filters: serializeColumnFiltersForUrl([1253 ...widgetFilters,1254 ...result.processedFilters.map(filter => ({1255 id: filter.id,1256 value: filter,1257 })),1258 ]),1259 global: null,1260 page: 0,1261 })1262 }1263 },1264 [columnFilters, setUrlParams],1265 )1266
1267 const resetAllState = useCallback(() => {1268 void setUrlParams({1269 page: null,1270 perPage: null,1271 sort: null,1272 filters: null,1273 search: null,1274 global: null,1275 cols: null,1276 })1277 }, [setUrlParams])1278
1279 return (1280 <div className="w-full space-y-4">1281 {error && (1282 <Card className="border-destructive">1283 <CardContent className="flex items-center gap-2 pt-6">1284 <AlertCircle className="size-5 text-destructive" />1285 <div className="flex-1">1286 <p className="text-sm font-medium text-destructive">1287 Error loading data1288 </p>1289 <p className="text-xs text-muted-foreground">{error}</p>1290 </div>1291 <Button1292 variant="outline"1293 size="sm"1294 onClick={() => refetch()}1295 disabled={isFetching}1296 >1297 Retry1298 </Button>1299 </CardContent>1300 </Card>1301 )}1302
1303 <DataTableRoot1304 data={data}1305 columns={columns}1306 isLoading={isLoading}1307 config={{1308 manualPagination: true,1309 manualSorting: true,1310 manualFiltering: true,1311 pageCount,1312 }}1313 state={{1314 pagination,1315 sorting,1316 columnFilters,1317 globalFilter,1318 columnVisibility,1319 }}1320 onPaginationChange={handlePaginationChange}1321 onSortingChange={handleSortingChange}1322 onColumnFiltersChange={handleColumnFiltersChange}1323 onGlobalFilterChange={handleGlobalFilterChange}1324 onColumnVisibilityChange={handleColumnVisibilityChange}1325 >1326 <DataTableToolbarSection>1327 <DataTableToolbarSection className="px-0">1328 <DataTableSearchFilter placeholder="Search products..." />1329 <DataTableViewMenu />1330 </DataTableToolbarSection>1331 {/* Facets on the left, sort/filter pushed to the right of the same1332 row (ml-auto). Wraps to a new line on narrow widths. */}1333 <DataTableToolbarSection className="flex-wrap px-0">1334 {/* Toolbar facets read server-computed counts. dynamicCounts /1335 limitToFilteredRows are off because the client holds only the1336 current page — counts and narrowing come from the server. */}1337 <DataTableFacetedFilter1338 accessorKey="category"1339 title="Category"1340 options={categoryFacetOptions}1341 multiple1342 dynamicCounts={false}1343 limitToFilteredRows={false}1344 />1345 <DataTableFacetedFilter1346 accessorKey="brand"1347 title="Brand"1348 options={brandFacetOptions}1349 multiple1350 dynamicCounts={false}1351 limitToFilteredRows={false}1352 />1353 {isPlaceholderData && isFetching && (1354 <Loader21355 className="ml-auto size-4 animate-spin text-muted-foreground"1356 aria-label="Loading new results"1357 />1358 )}1359 <DataTableSortMenu className="ml-auto" />1360 <DataTableFilterMenu1361 filters={menuFilters}1362 onFiltersChange={handleMenuFiltersChange}1363 />1364 </DataTableToolbarSection>1365 </DataTableToolbarSection>1366 {/* maxHeight keeps large page sizes scrollable instead of growing the page */}1367 <DataTable maxHeight={500}>1368 <DataTableHeader />1369 <DataTableBody>1370 <DataTableSkeleton rows={pagination.pageSize} />1371 <DataTableEmptyBody>1372 <DataTableEmptyMessage>1373 <DataTableEmptyIcon>1374 <Database className="size-12" />1375 </DataTableEmptyIcon>1376 <DataTableEmptyTitle>No products found</DataTableEmptyTitle>1377 <DataTableEmptyDescription>1378 There are no products to display at this time.1379 </DataTableEmptyDescription>1380 </DataTableEmptyMessage>1381 <DataTableEmptyFilteredMessage>1382 <DataTableEmptyIcon>1383 <SearchX className="size-12" />1384 </DataTableEmptyIcon>1385 <DataTableEmptyTitle>No matches found</DataTableEmptyTitle>1386 <DataTableEmptyDescription>1387 Try adjusting your filters or search to find what you're1388 looking for.1389 </DataTableEmptyDescription>1390 </DataTableEmptyFilteredMessage>1391 </DataTableEmptyBody>1392 </DataTableBody>1393 </DataTable>1394 <DataTablePagination1395 totalCount={totalCount}1396 isLoading={isLoading}1397 isFetching={isFetching}1398 />1399 </DataTableRoot>1400
1401 {/* Demo-only: the SQL a real Postgres would receive for this view */}1402 <Card>1403 <CardHeader>1404 <CardTitle className="flex items-center gap-2">1405 <Database className="size-4" aria-hidden="true" />1406 Generated SQL1407 </CardTitle>1408 <CardDescription>1409 Filter, search, and sort the table — this is the statement the1410 guide's Drizzle code produces for the current view (the demo1411 executes it with a mocked query builder in your browser).1412 </CardDescription>1413 <CardAction>1414 <Button variant="outline" size="sm" onClick={resetAllState}>1415 Reset All State1416 </Button>1417 </CardAction>1418 </CardHeader>1419 <CardContent className="space-y-3 text-xs">1420 <pre className="overflow-auto rounded bg-muted p-2">1421 {queryData?.debug.sql ?? "Loading..."}1422 </pre>1423 <div>1424 <span className="font-medium">Params: </span>1425 <code className="break-all">1426 {JSON.stringify(queryData?.debug.params ?? [])}1427 </code>1428 </div>1429 <div className="flex justify-between text-muted-foreground">1430 <span>1431 {totalCount} matching rows, showing {data.length}1432 </span>1433 <span>{isFetching ? "Executing..." : "Done"}</span>1434 </div>1435 </CardContent>1436 </Card>1437 </div>1438 )1439}1440
1441/**1442 * Self-contained wrapper. In a real app create the QueryClient once at your1443 * app root and wrap the layout with QueryClientProvider instead.1444 */1445export default function DrizzleNuqsTableExample() {1446 const [queryClient] = useState(1447 () =>1448 new QueryClient({1449 defaultOptions: {1450 queries: {1451 staleTime: 30 * 1000,1452 refetchOnWindowFocus: false,1453 retry: 1,1454 },1455 },1456 }),1457 )1458
1459 return (1460 <QueryClientProvider client={queryClient}>1461 <NuqsAdapter>1462 <DrizzleNuqsTableContent />1463 </NuqsAdapter>1464 </QueryClientProvider>1465 )1466}Installation
Section titled “Installation”Install the DataTable core and add-ons for this example:
pnpm dlx shadcn@latest add @niko-table/data-table @niko-table/data-table-pagination @niko-table/data-table-search-filter @niko-table/data-table-view-menu @niko-table/data-table-sort-menu @niko-table/data-table-filter-menu @niko-table/data-table-column-sort @niko-table/data-table-faceted-filter @niko-table/data-table-column-faceted-filter @niko-table/data-table-column-slider-filter @niko-table/data-table-column-date-filterInstall additional dependencies:
pnpm add @tanstack/react-query nuqsFirst time using
@niko-table? See the Installation Guide to set up the registry.
What changes vs the Drizzle guide
Section titled “What changes vs the Drizzle guide”Only the state layer. The data fetch, buildWhere / buildOrderBy / computeFacets, and the API route are identical.
- State lives in the URL.
useQueryStatesreplaces theuseStateslices for page, sorting, filters, search, and column visibility: same parsers as the Server-Side Nuqs Table. - The query key is the URL. TanStack Query keys off the URL-derived state, so a shared link resolves to the same cache entry, and an SSR page can run the same query from the incoming search params.
filterIdis stripped on write (serializeFiltersForUrl) and regenerated on read (normalizeFiltersFromUrl) to keep URLs short. The advanced menu’s AND rules live in thefiltersparam; OR/MIXED rules move to theglobalparam.
Toolbar facets on a server-side table
Section titled “Toolbar facets on a server-side table”The Category and Brand toolbar facets get their options from the server (computeFacets), so they need server counts, not client-derived ones:
1<DataTableFacetedFilter2 accessorKey="category"3 title="Category"4 options={categoryFacetOptions} // server counts, count-0 dropped, selection pinned5 multiple6 dynamicCounts={false} // don't recount from the client's current page7 limitToFilteredRows={false} // don't narrow from client rows either8/>The client only holds the current page, so counting and narrowing must come from the server. dynamicCounts={false} and limitToFilteredRows={false} turn off the client-side passes; the example narrows the list itself: dropping count === 0 options while keeping any currently-selected value so it stays removable.
When to Use
Section titled “When to Use”✅ Use Drizzle ORM + Nuqs when:
- You’re building the Drizzle ORM server-side table, and
- Views must be shareable, bookmarkable, or survive refreshes
- You want SSR to render the first paint from the URL’s search params
❌ Consider other options when:
- URL noise matters more than shareability: use the Drizzle ORM guide as-is
- You’re not on Drizzle: the Server-Side Nuqs Table shows the same URL layer over the database-agnostic wire contract
Next Steps
Section titled “Next Steps”- Drizzle ORM: the SQL layer (schema, column mapping, filters to WHERE, facets, API route)
- Server-Side Nuqs Table: the URL layer in depth
- nuqs Docs