Server-Side Nuqs Table
Server-side pagination, sorting, and filtering with the full table state persisted in the URL via nuqs.
Combines TanStack Query (data fetching, caching, request dedup) with nuqs URL state, so the URL itself becomes the cache key — share a link, and the recipient sees the exact same filtered, sorted, paginated view. Same database-agnostic wire contract as the Server-Side Table; read that page first, this one only covers the URL layer.
Live demo (mocked)
Section titled “Live demo (mocked)”The demo runs against a mock server — an in-memory array of 2,000 products plus simulated latency — so every filter works out of the box with no database; everything in its MOCK SERVER section is a stand-in for your backend. Try it: filter or sort, watch the “URL State” card update, and imagine pasting that URL to a teammate.
Product Name | Category | Brand | Price | Stock | Rating | In Stock | Release Date |
|---|---|---|---|---|---|---|---|
fetchProducts receives; swap it for a real API and any database works.{
"page": 0,
"pageSize": 10,
"sorting": [],
"search": "",
"columnFilters": []
}1"use client"2
3/**4 * Server-Side Data Table Example with TanStack Query + nuqs URL State5 *6 * Identical to the Server-Side Table example, except ALL table state7 * (pagination, sorting, filters, search, column visibility) lives in the8 * URL via nuqs — so any table view is shareable, bookmarkable, and survives9 * page refreshes. Data fetching still goes through TanStack Query.10 *11 * ## Database-agnostic by design12 *13 * The table never talks to a database. It talks to ONE function:14 *15 * fetchProducts(query: ProductQuery): Promise<ProductQueryResult>16 *17 * `ProductQuery` is a plain serializable object (page, pageSize, sorting,18 * search, filters) — exactly what you would POST to `/api/products` or pass19 * to a server action. Everything inside the "MOCK SERVER" section below is20 * a stand-in for YOUR backend: replace it with SQL, an ORM (Prisma,21 * Drizzle), Supabase, or any REST/GraphQL API. The rest of the file does22 * not change. Because the state is already serialized in the URL, a23 * server-rendered page can even run the query during SSR from the same24 * params.25 *26 * Prerequisites:27 * npm install @tanstack/react-query nuqs28 *29 * This example creates its own QueryClientProvider and NuqsAdapter so it is30 * self-contained. In a real app, put both providers in your root layout and31 * pick the adapter for your framework:32 *33 * - Next.js App Router: import { NuqsAdapter } from "nuqs/adapters/next/app"34 * - Next.js Pages Router: import { NuqsAdapter } from "nuqs/adapters/next/pages"35 * - React SPA (Vite, ..): import { NuqsAdapter } from "nuqs/adapters/react"36 *37 * See https://nuqs.dev/docs/adapters and the Server-Side Nuqs Table docs.38 */39
40import { useCallback, useMemo, useState } from "react"41import {42 keepPreviousData,43 QueryClient,44 QueryClientProvider,45 useQuery,46} from "@tanstack/react-query"47import { NuqsAdapter } from "nuqs/adapters/react"48import {49 parseAsInteger,50 parseAsJson,51 parseAsString,52 useQueryStates,53} from "nuqs"54import type {55 ColumnFiltersState,56 PaginationState,57 SortingState,58 Updater,59 VisibilityState,60} from "@tanstack/react-table"61import { DataTableRoot } from "@/components/niko-table/core/data-table-root"62import { DataTable } from "@/components/niko-table/core/data-table"63import {64 DataTableHeader,65 DataTableBody,66 DataTableEmptyBody,67 DataTableSkeleton,68} from "@/components/niko-table/core/data-table-structure"69import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"70import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"71import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"72import { DataTableColumnFacetedFilterMenu } from "@/components/niko-table/components/data-table-column-faceted-filter"73import { DataTableColumnSliderFilterMenu } from "@/components/niko-table/components/data-table-column-slider-filter-options"74import { DataTableColumnDateFilterMenu } from "@/components/niko-table/components/data-table-column-date-filter-options"75import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"76import {77 DataTableEmptyIcon,78 DataTableEmptyMessage,79 DataTableEmptyFilteredMessage,80 DataTableEmptyTitle,81 DataTableEmptyDescription,82} from "@/components/niko-table/components/data-table-empty-state"83import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"84import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"85import { DataTableSortMenu } from "@/components/niko-table/components/data-table-sort-menu"86import { DataTableFilterMenu } from "@/components/niko-table/components/data-table-filter-menu"87import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"88import { daysAgo } from "@/components/niko-table/lib/format"89import {90 FILTER_OPERATORS,91 FILTER_VARIANTS,92} from "@/components/niko-table/lib/constants"93import { processFiltersForLogic } from "@/components/niko-table/lib/data-table"94import {95 normalizeFiltersFromUrl,96 serializeFiltersForUrl,97} from "@/components/niko-table/filters/table-filter-menu"98import { useDebounce } from "@/components/niko-table/hooks/use-debounce"99import type {100 DataTableColumnDef,101 ExtendedColumnFilter,102} from "@/components/niko-table/types"103import { Badge } from "@/components/ui/badge"104import { Button } from "@/components/ui/button"105import {106 Card,107 CardAction,108 CardContent,109 CardDescription,110 CardHeader,111 CardTitle,112} from "@/components/ui/card"113import { AlertCircle, Loader2, SearchX, UserSearch } from "lucide-react"114
115/* -------------------------------------------------------------------------116 * 1. The wire contract — what the table sends to YOUR backend117 *118 * Every field is JSON-serializable, so the same shape works as query params,119 * a POST body, or server-action arguments. In this example the shape also120 * IS the URL: nuqs stores each slice as a search param.121 * ---------------------------------------------------------------------- */122
123type Product = {124 id: string125 name: string126 category: string127 brand: string128 price: number129 stock: number130 rating: number131 inStock: boolean132 releaseDate: Date133}134
135/**136 * The request. This is ALL the backend needs to build a query:137 *138 * - `page` / `pageSize` → LIMIT / OFFSET139 * - `sorting` → ORDER BY (multi-column)140 * - `search` → global text search. When the advanced filter menu141 * uses OR/MIXED join logic it stores an object142 * `{ filters, joinOperator }` here instead of a143 * string (that is how Niko Table routes OR logic).144 * - `columnFilters` → WHERE clauses. Each entry is `{ id, value }`;145 * `value` is either a plain value from a column146 * header widget (string, string[], boolean, or a147 * `[min, max]` tuple from slider/date filters) or an148 * `ExtendedColumnFilter` (`{ operator, value, ... }`)149 * from the advanced filter menu.150 */151type ProductQuery = {152 page: number153 pageSize: number154 sorting: SortingState155 search: string | object156 columnFilters: ColumnFiltersState157}158
159/**160 * The response. `facets` powers cross-filter narrowing: the server reports,161 * for each facetable column, which values still exist (and their counts)162 * under every OTHER active filter — so selecting a brand narrows the163 * category options, exactly like a shopping site sidebar.164 */165type ProductQueryResult = {166 data: Product[]167 total: number168 facets: {169 select: Record<string, Array<{ value: string; count: number }>>170 range: Record<string, [number, number]>171 }172}173
174/* -------------------------------------------------------------------------175 * 2. MOCK SERVER — replace this whole section with your backend176 *177 * Everything between here and "3. Columns" simulates a database + API with178 * an in-memory array and setTimeout latency. To adapt it, translate each179 * operator to your query builder. With SQL for example:180 *181 * FILTER_OPERATORS.ILIKE → WHERE name ILIKE '%' || $1 || '%'182 * FILTER_OPERATORS.EQ / NEQ → WHERE brand = $1 / <> $1183 * FILTER_OPERATORS.GT/GTE/... → WHERE price > $1 / >= $1 ...184 * FILTER_OPERATORS.IN → WHERE category = ANY($1)185 * FILTER_OPERATORS.EMPTY → WHERE col IS NULL OR col = ''186 * [min, max] range tuples → WHERE price BETWEEN $1 AND $2187 * query.search (string) → WHERE to_tsvector(...) @@ $1 (or ILIKE)188 * query.sorting → ORDER BY col1 ASC, col2 DESC189 * query.page / pageSize → LIMIT $2 OFFSET $1 * $2190 *191 * The facet computation maps to grouped counts with each column's own192 * filter excluded: SELECT category, COUNT(*) ... GROUP BY category.193 * ---------------------------------------------------------------------- */194
195// The "database" — 15 base products expanded to 2,000 rows.196const initialData: Product[] = [197 {198 id: "1",199 name: "iPhone 15 Pro",200 category: "electronics",201 brand: "apple",202 price: 999,203 stock: 45,204 rating: 5,205 inStock: true,206 releaseDate: daysAgo(5),207 },208 {209 id: "2",210 name: "Galaxy S24 Ultra",211 category: "electronics",212 brand: "samsung",213 price: 1199,214 stock: 32,215 rating: 5,216 inStock: true,217 releaseDate: daysAgo(10),218 },219 {220 id: "3",221 name: "Air Jordan 1",222 category: "sports",223 brand: "nike",224 price: 170,225 stock: 8,226 rating: 4,227 inStock: true,228 releaseDate: daysAgo(25),229 },230 {231 id: "4",232 name: "Ultraboost 23",233 category: "sports",234 brand: "adidas",235 price: 190,236 stock: 15,237 rating: 4,238 inStock: true,239 releaseDate: daysAgo(50),240 },241 {242 id: "5",243 name: "PlayStation 5",244 category: "electronics",245 brand: "sony",246 price: 499,247 stock: 0,248 rating: 5,249 inStock: false,250 releaseDate: daysAgo(365),251 },252 {253 id: "6",254 name: "OLED C3 TV",255 category: "electronics",256 brand: "lg",257 price: 1499,258 stock: 12,259 rating: 5,260 inStock: true,261 releaseDate: daysAgo(90),262 },263 {264 id: "7",265 name: "XPS 15 Laptop",266 category: "electronics",267 brand: "dell",268 price: 1899,269 stock: 20,270 rating: 4,271 inStock: true,272 releaseDate: daysAgo(120),273 },274 {275 id: "8",276 name: "Spectre x360",277 category: "electronics",278 brand: "hp",279 price: 1599,280 stock: 18,281 rating: 4,282 inStock: true,283 releaseDate: daysAgo(15),284 },285 {286 id: "9",287 name: "MacBook Pro 16",288 category: "electronics",289 brand: "apple",290 price: 2499,291 stock: 25,292 rating: 5,293 inStock: true,294 releaseDate: daysAgo(30),295 },296 {297 id: "10",298 name: "Galaxy Book3",299 category: "electronics",300 brand: "samsung",301 price: 1399,302 stock: 14,303 rating: 4,304 inStock: true,305 releaseDate: daysAgo(180),306 },307 {308 id: "11",309 name: "Running Shorts",310 category: "clothing",311 brand: "nike",312 price: 45,313 stock: 120,314 rating: 3,315 inStock: true,316 releaseDate: daysAgo(60),317 },318 {319 id: "12",320 name: "Training Jacket",321 category: "clothing",322 brand: "adidas",323 price: 85,324 stock: 65,325 rating: 4,326 inStock: true,327 releaseDate: daysAgo(45),328 },329 {330 id: "13",331 name: "Garden Tools Set",332 category: "home-garden",333 brand: "hp",334 price: 120,335 stock: 30,336 rating: 4,337 inStock: true,338 releaseDate: daysAgo(75),339 },340 {341 id: "14",342 name: "Programming Book",343 category: "books",344 brand: "dell",345 price: 60,346 stock: 50,347 rating: 5,348 inStock: true,349 releaseDate: daysAgo(200),350 },351 {352 id: "15",353 name: "Wireless Mouse",354 category: "electronics",355 brand: "lg",356 price: 35,357 stock: 200,358 rating: 3,359 inStock: true,360 releaseDate: daysAgo(150),361 },362]363
364function generateMockProducts(count: number): Product[] {365 const result: Product[] = []366 const baseCount = initialData.length367
368 for (let i = 0; i < count; i++) {369 const baseProduct = initialData[i % baseCount]370 const variation = Math.floor(i / baseCount)371
372 result.push({373 ...baseProduct,374 id: `${baseProduct.id}-${variation}`,375 name:376 variation > 0377 ? `${baseProduct.name} (${variation + 1})`378 : baseProduct.name,379 price: baseProduct.price + variation * 10,380 stock: Math.max(0, baseProduct.stock - variation * 5),381 rating: baseProduct.rating,382 inStock: baseProduct.stock - variation * 5 > 0,383 releaseDate: new Date(384 baseProduct.releaseDate.getTime() - variation * 24 * 60 * 60 * 1000,385 ),386 })387 }388
389 return result390}391
392/** Match one advanced-filter-menu rule (`ExtendedColumnFilter`). */393function matchesFilter(394 product: Product,395 filter: ExtendedColumnFilter<Product>,396): boolean {397 const productValue = product[filter.id as keyof Product]398 const filterValue = filter.value399
400 if (401 filter.operator === FILTER_OPERATORS.EMPTY ||402 filter.operator === FILTER_OPERATORS.NOT_EMPTY403 ) {404 // These operators don't need a value405 } else if (!filterValue || filterValue === "") {406 return true407 }408
409 switch (filter.operator) {410 case FILTER_OPERATORS.EQ:411 return (412 String(productValue).toLowerCase() === String(filterValue).toLowerCase()413 )414 case FILTER_OPERATORS.NEQ:415 return (416 String(productValue).toLowerCase() !== String(filterValue).toLowerCase()417 )418 case FILTER_OPERATORS.ILIKE:419 return String(productValue)420 .toLowerCase()421 .includes(String(filterValue).toLowerCase())422 case FILTER_OPERATORS.NOT_ILIKE:423 return !String(productValue)424 .toLowerCase()425 .includes(String(filterValue).toLowerCase())426 case FILTER_OPERATORS.GT:427 return Number(productValue) > Number(filterValue)428 case FILTER_OPERATORS.LT:429 return Number(productValue) < Number(filterValue)430 case FILTER_OPERATORS.GTE:431 return Number(productValue) >= Number(filterValue)432 case FILTER_OPERATORS.LTE:433 return Number(productValue) <= Number(filterValue)434 case FILTER_OPERATORS.EMPTY:435 return (436 productValue === null ||437 productValue === undefined ||438 String(productValue).trim() === ""439 )440 case FILTER_OPERATORS.NOT_EMPTY:441 return (442 productValue !== null &&443 productValue !== undefined &&444 String(productValue).trim() !== ""445 )446 case FILTER_OPERATORS.IN:447 if (Array.isArray(filterValue)) {448 return filterValue.some(449 v => String(productValue).toLowerCase() === String(v).toLowerCase(),450 )451 }452 return false453 case FILTER_OPERATORS.NOT_IN:454 if (Array.isArray(filterValue)) {455 return !filterValue.some(456 v => String(productValue).toLowerCase() === String(v).toLowerCase(),457 )458 }459 return true460 default:461 return true462 }463}464
465/**466 * Match one entry of `query.columnFilters` against a product.467 *468 * Column-header widgets write PLAIN values (`column.setFilterValue(raw)`):469 * `[min, max]` tuples from slider/date menus, `string[]` from faceted470 * multi-select, booleans, strings. The advanced filter menu writes471 * `ExtendedColumnFilter` objects with an `operator`. Both shapes travel in472 * the same array, so dispatch on shape.473 */474function matchesColumnFilter(475 product: Product,476 columnId: string,477 value: unknown,478): boolean {479 if (value === null || value === undefined || value === "") return true480
481 if (482 typeof value === "object" &&483 !Array.isArray(value) &&484 "operator" in value485 ) {486 return matchesFilter(product, value as ExtendedColumnFilter<Product>)487 }488
489 const productValue = product[columnId as keyof Product]490
491 // Single-date filter: a bare millisecond timestamp — match the calendar day492 if (productValue instanceof Date && typeof value === "number") {493 const filterDate = new Date(value)494 return (495 productValue.getFullYear() === filterDate.getFullYear() &&496 productValue.getMonth() === filterDate.getMonth() &&497 productValue.getDate() === filterDate.getDate()498 )499 }500
501 // [min, max] range tuple from slider or date-range filters502 if (Array.isArray(value) && value.length === 2 && !isStringArray(value)) {503 const [a, b] = value as [unknown, unknown]504 if (a == null && b == null) return true505 const productNum =506 productValue instanceof Date507 ? productValue.getTime()508 : Number(productValue)509 const lo = a == null ? -Infinity : toNumber(a)510 const hi = b == null ? Infinity : toNumber(b)511 return productNum >= lo && productNum <= hi512 }513
514 // string[] from faceted multi-select515 if (Array.isArray(value)) {516 if (value.length === 0) return true517 return value.some(518 v => String(productValue).toLowerCase() === String(v).toLowerCase(),519 )520 }521
522 if (typeof value === "boolean") {523 return Boolean(productValue) === value524 }525
526 if (typeof value === "string" || typeof value === "number") {527 return String(productValue)528 .toLowerCase()529 .includes(String(value).toLowerCase())530 }531
532 return true533}534
535function isStringArray(arr: unknown[]): boolean {536 return arr.every(v => typeof v === "string")537}538
539function toNumber(v: unknown): number {540 if (v instanceof Date) return v.getTime()541 return Number(v)542}543
544/** True when a columnFilters entry value came from the advanced filter menu. */545function isMenuFilterValue(546 value: unknown,547): value is ExtendedColumnFilter<Product> {548 return (549 !!value &&550 typeof value === "object" &&551 !Array.isArray(value) &&552 "operator" in value553 )554}555
556/**557 * Apply every filter in the query, optionally excluding one column.558 * The exclusion is used for facet computation: a column's own facet counts559 * are computed under every filter EXCEPT its own, so its unselected options560 * stay visible.561 */562function filterProductsByQuery(563 products: Product[],564 query: ProductQuery,565 excludeColumnId?: string,566): Product[] {567 let filtered = [...products]568
569 // Global text search across all fields570 if (typeof query.search === "string" && query.search) {571 const searchTerm = query.search.toLowerCase()572 filtered = filtered.filter(product =>573 Object.values(product).some(value =>574 String(value).toLowerCase().includes(searchTerm),575 ),576 )577 }578
579 // OR / MIXED logic from the advanced filter menu (routed via globalFilter)580 if (581 typeof query.search === "object" &&582 query.search &&583 "filters" in query.search584 ) {585 const filterObj = query.search as {586 filters: ExtendedColumnFilter<Product>[]587 joinOperator: string588 }589 const orFilters = (filterObj.filters || []).filter(590 f =>591 f.value &&592 f.value !== "" &&593 (!excludeColumnId || f.id !== excludeColumnId),594 )595
596 if (orFilters.length > 0) {597 filtered = filtered.filter(product =>598 orFilters.some(filter => matchesFilter(product, filter)),599 )600 }601 }602
603 // AND logic: every columnFilters entry must match604 if (query.columnFilters.length > 0) {605 filtered = filtered.filter(product =>606 query.columnFilters.every(filter => {607 if (excludeColumnId && filter.id === excludeColumnId) return true608 return matchesColumnFilter(product, filter.id, filter.value)609 }),610 )611 }612
613 return filtered614}615
616// Columns that get server-computed facets617const FACET_SELECT_COLUMNS = ["category", "brand"] as const618const FACET_RANGE_COLUMNS = ["price"] as const619
620/**621 * The fake API endpoint. Swap this single function for a real call:622 *623 * async function fetchProducts(query: ProductQuery) {624 * const res = await fetch("/api/products", {625 * method: "POST",626 * body: JSON.stringify(query),627 * })628 * if (!res.ok) throw new Error("Failed to fetch products")629 * return res.json() as Promise<ProductQueryResult>630 * }631 */632function fetchProducts(633 query: ProductQuery,634 delay = 500,635): Promise<ProductQueryResult> {636 return new Promise((resolve, reject) => {637 setTimeout(() => {638 try {639 // Simulate an occasional server error (every 20th page) so the640 // error + retry UI can be demonstrated641 if (query.page > 0 && query.page % 20 === 0) {642 reject(new Error("Server error: Failed to fetch products"))643 return644 }645
646 const allProducts = generateMockProducts(2000)647 const filtered = filterProductsByQuery(allProducts, query)648
649 // Cross-filter facets (each column's own filter excluded)650 const facets: ProductQueryResult["facets"] = { select: {}, range: {} }651 for (const col of FACET_SELECT_COLUMNS) {652 const facetFiltered = filterProductsByQuery(allProducts, query, col)653 const counts = new Map<string, number>()654 for (const p of facetFiltered) {655 const v = String(p[col])656 if (!v.trim()) continue657 counts.set(v, (counts.get(v) ?? 0) + 1)658 }659 facets.select[col] = [...counts.entries()]660 .map(([value, count]) => ({ value, count }))661 .sort((a, b) => a.value.localeCompare(b.value))662 }663 for (const col of FACET_RANGE_COLUMNS) {664 const facetFiltered = filterProductsByQuery(allProducts, query, col)665 if (facetFiltered.length === 0) continue666 let lo = Infinity667 let hi = -Infinity668 for (const p of facetFiltered) {669 const v = Number(p[col])670 if (Number.isFinite(v)) {671 if (v < lo) lo = v672 if (v > hi) hi = v673 }674 }675 if (Number.isFinite(lo) && Number.isFinite(hi)) {676 facets.range[col] = [lo, hi]677 }678 }679
680 // ORDER BY681 if (query.sorting.length > 0) {682 filtered.sort((a, b) => {683 for (const sort of query.sorting) {684 const aValue = a[sort.id as keyof Product]685 const bValue = b[sort.id as keyof Product]686 if (aValue === bValue) continue687 const comparison = aValue < bValue ? -1 : 1688 return sort.desc ? -comparison : comparison689 }690 return 0691 })692 }693
694 // LIMIT / OFFSET695 const total = filtered.length696 const start = query.page * query.pageSize697 const paginated = filtered.slice(start, start + query.pageSize)698
699 resolve({ data: paginated, total, facets })700 } catch (error) {701 reject(error)702 }703 }, delay)704 })705}706
707/* -------------------------------------------------------------------------708 * 3. Columns709 *710 * `autoOptions: false` everywhere options appear: with server-side data the711 * table only ever holds ONE page of rows, so client-side option generation712 * would be wrong. Options and ranges come from `facets` instead.713 * ---------------------------------------------------------------------- */714
715const categoryOptions = [716 { label: "Electronics", value: "electronics" },717 { label: "Clothing", value: "clothing" },718 { label: "Home & Garden", value: "home-garden" },719 { label: "Sports", value: "sports" },720 { label: "Books", value: "books" },721]722
723const brandOptions = [724 { label: "Apple", value: "apple" },725 { label: "Samsung", value: "samsung" },726 { label: "Nike", value: "nike" },727 { label: "Adidas", value: "adidas" },728 { label: "Sony", value: "sony" },729 { label: "LG", value: "lg" },730 { label: "Dell", value: "dell" },731 { label: "HP", value: "hp" },732]733
734type ProductFacets = ProductQueryResult["facets"]735
736/**737 * Build columns from the latest server facets. Faceted columns receive738 * merged `options` (static labels + server counts) so unselected values stay739 * visible after other filters narrow the row set; the price slider receives740 * the server-computed `range` so it can always be widened back.741 */742function buildColumns(facets?: ProductFacets): DataTableColumnDef<Product>[] {743 const mergeCounts = (744 staticOpts: typeof categoryOptions,745 facet: Array<{ value: string; count: number }> | undefined,746 ) => {747 if (!facet) return staticOpts748 const m = new Map(facet.map(f => [f.value, f.count]))749 return staticOpts.map(opt => ({ ...opt, count: m.get(opt.value) ?? 0 }))750 }751
752 const categoryOpts = mergeCounts(categoryOptions, facets?.select.category)753 const brandOpts = mergeCounts(brandOptions, facets?.select.brand)754 const priceRange = facets?.range.price755
756 return [757 {758 accessorKey: "name",759 header: () => (760 <DataTableColumnHeader>761 <DataTableColumnTitle />762 <DataTableColumnSortMenu />763 </DataTableColumnHeader>764 ),765 meta: {766 label: "Product Name",767 variant: FILTER_VARIANTS.TEXT,768 },769 enableColumnFilter: true,770 },771 {772 accessorKey: "category",773 header: () => (774 <DataTableColumnHeader>775 <DataTableColumnTitle />776 <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />777 <DataTableColumnFacetedFilterMenu options={categoryOpts} />778 </DataTableColumnHeader>779 ),780 meta: {781 label: "Category",782 variant: FILTER_VARIANTS.SELECT,783 options: categoryOptions,784 autoOptions: false,785 },786 cell: ({ row }) => {787 const category = row.getValue("category") as string788 const option = categoryOptions.find(opt => opt.value === category)789 return <span>{option?.label || category}</span>790 },791 enableColumnFilter: true,792 },793 {794 accessorKey: "brand",795 header: () => (796 <DataTableColumnHeader>797 <DataTableColumnTitle />798 <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />799 <DataTableColumnFacetedFilterMenu options={brandOpts} />800 </DataTableColumnHeader>801 ),802 meta: {803 label: "Brand",804 variant: FILTER_VARIANTS.SELECT,805 options: brandOptions,806 autoOptions: false,807 },808 enableColumnFilter: true,809 },810 {811 accessorKey: "price",812 header: () => (813 <DataTableColumnHeader>814 <DataTableColumnTitle />815 <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />816 <DataTableColumnSliderFilterMenu range={priceRange} />817 </DataTableColumnHeader>818 ),819 meta: {820 label: "Price",821 unit: "$",822 variant: FILTER_VARIANTS.NUMBER,823 },824 cell: ({ row }) => {825 const price = parseFloat(row.getValue("price"))826 return <div className="font-medium">${price.toFixed(2)}</div>827 },828 enableColumnFilter: true,829 },830 {831 accessorKey: "stock",832 header: () => (833 <DataTableColumnHeader>834 <DataTableColumnTitle />835 <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />836 </DataTableColumnHeader>837 ),838 meta: {839 label: "Stock",840 variant: FILTER_VARIANTS.NUMBER,841 },842 cell: ({ row }) => {843 const stock = Number(row.getValue("stock"))844 return (845 <div className={stock < 10 ? "font-medium text-destructive" : ""}>846 {stock}847 </div>848 )849 },850 enableColumnFilter: true,851 },852 {853 accessorKey: "rating",854 header: () => (855 <DataTableColumnHeader>856 <DataTableColumnTitle />857 <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />858 </DataTableColumnHeader>859 ),860 meta: {861 label: "Rating",862 variant: FILTER_VARIANTS.NUMBER,863 },864 cell: ({ row }) => {865 const rating = Number(row.getValue("rating"))866 return (867 <div className="flex items-center gap-1">868 <span>{rating}</span>869 <span aria-hidden="true">★</span>870 </div>871 )872 },873 enableColumnFilter: true,874 },875 {876 accessorKey: "inStock",877 header: () => (878 <DataTableColumnHeader>879 <DataTableColumnTitle />880 <DataTableColumnSortMenu />881 <DataTableColumnFacetedFilterMenu />882 </DataTableColumnHeader>883 ),884 meta: {885 label: "In Stock",886 variant: FILTER_VARIANTS.BOOLEAN,887 },888 cell: ({ row }) => {889 const inStock = Boolean(row.getValue("inStock"))890 return (891 <Badge variant={inStock ? "default" : "secondary"}>892 {inStock ? "Yes" : "No"}893 </Badge>894 )895 },896 enableColumnFilter: true,897 },898 {899 accessorKey: "releaseDate",900 header: () => (901 <DataTableColumnHeader>902 <DataTableColumnTitle />903 <DataTableColumnSortMenu />904 <DataTableColumnDateFilterMenu />905 </DataTableColumnHeader>906 ),907 meta: {908 label: "Release Date",909 variant: FILTER_VARIANTS.DATE,910 },911 cell: ({ row }) => {912 const date = row.getValue("releaseDate") as Date913 return <span>{date.toLocaleDateString()}</span>914 },915 enableColumnFilter: true,916 },917 ]918}919
920/* -------------------------------------------------------------------------921 * 4. URL state (nuqs)922 *923 * One parser per state slice. The parser keys double as the URL param924 * names, e.g. ?page=2&perPage=20&sort=[{"id":"price","desc":true}]925 * ---------------------------------------------------------------------- */926
927/** OR/MIXED filter payload from the advanced filter menu. */928type GlobalFilterObject = {929 filters: ExtendedColumnFilter<Product>[]930 joinOperator: string931}932
933const tableStateParsers = {934 page: parseAsInteger.withDefault(0),935 perPage: parseAsInteger.withDefault(10),936 sort: parseAsJson<SortingState>(value => value as SortingState).withDefault(937 [],938 ),939 // Mixed-shape columnFilters array (widget values + menu filter objects);940 // filterIds are stripped on write and regenerated on read to keep URLs short941 filters: parseAsJson<ColumnFiltersState>(942 value => value as ColumnFiltersState,943 ).withDefault([]),944 search: parseAsString.withDefault(""),945 // OR/MIXED advanced filters — only ever an object, never a string.946 // No default: nuqs yields `null` when the param is absent947 global: parseAsJson<GlobalFilterObject | null>(value => {948 if (value && typeof value === "object" && "filters" in value) {949 return value as GlobalFilterObject950 }951 return null952 }),953 cols: parseAsJson<VisibilityState>(954 value => value as VisibilityState,955 ).withDefault({}),956}957
958/** Strip filterIds from menu-authored entries to keep URLs short. */959function serializeColumnFiltersForUrl(960 filters: ColumnFiltersState,961): ColumnFiltersState {962 return filters.map(f => {963 if (isMenuFilterValue(f.value)) {964 const [serialized] = serializeFiltersForUrl([f.value])965 return { id: f.id, value: serialized }966 }967 return f968 })969}970
971/* -------------------------------------------------------------------------972 * 5. The table973 * ---------------------------------------------------------------------- */974
975function ServerSideNuqsTableContent() {976 const [urlParams, setUrlParams] = useQueryStates(tableStateParsers, {977 history: "replace",978 scroll: false,979 shallow: true,980 })981
982 // URL → TanStack table state983 const pagination = useMemo<PaginationState>(984 () => ({ pageIndex: urlParams.page, pageSize: urlParams.perPage }),985 [urlParams.page, urlParams.perPage],986 )987 const sorting = urlParams.sort988 const columnFilters = urlParams.filters989 const columnVisibility = urlParams.cols990 // string search and the OR/MIXED filter object share the globalFilter slot991 const globalFilter: string | object = urlParams.global ?? urlParams.search992
993 // Batch rapid filter clicks (e.g. toggling several faceted options) into a994 // single server request995 // Debounce search + columnFilters as ONE snapshot, not separately. A single996 // advanced-menu action can write both URL params at once; debouncing them997 // on their own clocks would let the undebounced field reach the request998 // slightly ahead of the other, transiently mixing a new filter with a999 // stale one (and the reverse on clear). Sorting/pagination stay undebounced.1000 const debouncedInput = useDebounce(1001 useMemo(1002 () => ({ search: globalFilter, columnFilters }),1003 [globalFilter, columnFilters],1004 ),1005 300,1006 )1007
1008 const {1009 data: queryData,1010 isLoading,1011 error: queryError,1012 isFetching,1013 isPlaceholderData,1014 refetch,1015 } = useQuery({1016 // Include every parameter that affects the result1017 queryKey: [1018 "products",1019 pagination.pageIndex,1020 pagination.pageSize,1021 sorting,1022 debouncedInput.search,1023 debouncedInput.columnFilters,1024 ],1025 queryFn: () =>1026 fetchProducts({1027 page: pagination.pageIndex,1028 pageSize: pagination.pageSize,1029 sorting,1030 search: debouncedInput.search,1031 columnFilters: debouncedInput.columnFilters,1032 }),1033 placeholderData: keepPreviousData, // keep rows visible while refetching1034 })1035
1036 const data = queryData?.data ?? []1037 const totalCount = queryData?.total ?? 01038 const pageCount =1039 totalCount > 0 ? Math.ceil(totalCount / pagination.pageSize) : 11040
1041 // Rebuild columns whenever the server reports new facets1042 const columns = useMemo(1043 () => buildColumns(queryData?.facets),1044 [queryData?.facets],1045 )1046
1047 const error =1048 queryError instanceof Error1049 ? queryError.message1050 : queryError1051 ? "Failed to fetch data"1052 : null1053
1054 // TanStack table state → URL. Every filter/sort change resets to the1055 // first page — the old page index may not exist in the new result set1056 const handlePaginationChange = useCallback(1057 (updater: Updater<PaginationState>) => {1058 const next = typeof updater === "function" ? updater(pagination) : updater1059 void setUrlParams({ page: next.pageIndex, perPage: next.pageSize })1060 },1061 [pagination, setUrlParams],1062 )1063
1064 const handleSortingChange = useCallback(1065 (updater: Updater<SortingState>) => {1066 const next = typeof updater === "function" ? updater(sorting) : updater1067 void setUrlParams({ sort: next.length > 0 ? next : null, page: 0 })1068 },1069 [sorting, setUrlParams],1070 )1071
1072 const handleColumnFiltersChange = useCallback(1073 (updater: Updater<ColumnFiltersState>) => {1074 const next =1075 typeof updater === "function" ? updater(columnFilters) : updater1076 void setUrlParams({1077 filters: next.length > 0 ? serializeColumnFiltersForUrl(next) : null,1078 page: 0,1079 })1080 },1081 [columnFilters, setUrlParams],1082 )1083
1084 const handleColumnVisibilityChange = useCallback(1085 (updater: Updater<VisibilityState>) => {1086 const next =1087 typeof updater === "function" ? updater(columnVisibility) : updater1088 void setUrlParams({ cols: Object.keys(next).length > 0 ? next : null })1089 },1090 [columnVisibility, setUrlParams],1091 )1092
1093 const handleGlobalFilterChange = useCallback(1094 (value: string | object) => {1095 // Only search strings arrive here; OR/MIXED objects are written by1096 // handleMenuFiltersChange. The search input emits "" on mount/clear —1097 // don't let that wipe an active OR-filter object1098 if (typeof value !== "string") return1099 if (value === "" && urlParams.global) return1100 void setUrlParams({ search: value || null, page: 0 })1101 },1102 [urlParams.global, setUrlParams],1103 )1104
1105 /**1106 * The advanced filter menu is controlled: its rules live in the URL so1107 * they can be routed to the right slot. AND-joined rules become1108 * columnFilters entries (alongside the column-widget filters); OR/MIXED1109 * rules move into the `global` param as `{ filters, joinOperator }`,1110 * because TanStack combines columnFilters with AND only.1111 */1112 const menuFilters = useMemo(() => {1113 if (urlParams.global) {1114 return normalizeFiltersFromUrl(urlParams.global.filters ?? [])1115 }1116 return normalizeFiltersFromUrl(1117 columnFilters.map(cf => cf.value).filter(isMenuFilterValue),1118 )1119 }, [urlParams.global, columnFilters])1120
1121 const handleMenuFiltersChange = useCallback(1122 (filters: ExtendedColumnFilter<Product>[] | null) => {1123 const next = filters ?? []1124 // Column-widget filters (faceted, slider, date) are preserved; only1125 // the menu-owned entries are rewritten1126 const widgetFilters = columnFilters.filter(1127 f => !isMenuFilterValue(f.value),1128 )1129 if (next.length === 0) {1130 void setUrlParams({1131 filters:1132 widgetFilters.length > 01133 ? serializeColumnFiltersForUrl(widgetFilters)1134 : null,1135 global: null,1136 page: 0,1137 })1138 return1139 }1140 const result = processFiltersForLogic(next)1141 if (result.shouldUseGlobalFilter) {1142 void setUrlParams({1143 filters:1144 widgetFilters.length > 01145 ? serializeColumnFiltersForUrl(widgetFilters)1146 : null,1147 global: {1148 filters: serializeFiltersForUrl(1149 result.processedFilters,1150 ) as ExtendedColumnFilter<Product>[],1151 joinOperator: result.joinOperator,1152 },1153 page: 0,1154 })1155 } else {1156 void setUrlParams({1157 filters: serializeColumnFiltersForUrl([1158 ...widgetFilters,1159 ...result.processedFilters.map(filter => ({1160 id: filter.id,1161 value: filter,1162 })),1163 ]),1164 global: null,1165 page: 0,1166 })1167 }1168 },1169 [columnFilters, setUrlParams],1170 )1171
1172 const resetAllState = useCallback(() => {1173 void setUrlParams({1174 page: null,1175 perPage: null,1176 sort: null,1177 filters: null,1178 search: null,1179 global: null,1180 cols: null,1181 })1182 }, [setUrlParams])1183
1184 return (1185 <div className="w-full space-y-4">1186 {error && (1187 <Card className="border-destructive">1188 <CardContent className="flex items-center gap-2 pt-6">1189 <AlertCircle className="size-5 text-destructive" />1190 <div className="flex-1">1191 <p className="text-sm font-medium text-destructive">1192 Error loading data1193 </p>1194 <p className="text-xs text-muted-foreground">{error}</p>1195 </div>1196 <Button1197 variant="outline"1198 size="sm"1199 onClick={() => refetch()}1200 disabled={isFetching}1201 >1202 Retry1203 </Button>1204 </CardContent>1205 </Card>1206 )}1207
1208 <DataTableRoot1209 data={data}1210 columns={columns}1211 isLoading={isLoading}1212 config={{1213 manualPagination: true,1214 manualSorting: true,1215 manualFiltering: true,1216 pageCount,1217 }}1218 state={{1219 pagination,1220 sorting,1221 columnFilters,1222 globalFilter,1223 columnVisibility,1224 }}1225 onPaginationChange={handlePaginationChange}1226 onSortingChange={handleSortingChange}1227 onColumnFiltersChange={handleColumnFiltersChange}1228 onGlobalFilterChange={handleGlobalFilterChange}1229 onColumnVisibilityChange={handleColumnVisibilityChange}1230 >1231 <DataTableToolbarSection>1232 <DataTableToolbarSection className="px-0">1233 <DataTableSearchFilter placeholder="Search products..." />1234 <DataTableViewMenu />1235 </DataTableToolbarSection>1236 <DataTableToolbarSection className="px-0">1237 {isPlaceholderData && isFetching && (1238 <Loader21239 className="size-4 animate-spin text-muted-foreground"1240 aria-label="Loading new results"1241 />1242 )}1243 <DataTableSortMenu className="ml-auto" />1244 <DataTableFilterMenu1245 filters={menuFilters}1246 onFiltersChange={handleMenuFiltersChange}1247 />1248 </DataTableToolbarSection>1249 </DataTableToolbarSection>1250 {/* maxHeight keeps large page sizes scrollable instead of growing the page */}1251 <DataTable maxHeight={500}>1252 <DataTableHeader />1253 <DataTableBody>1254 <DataTableSkeleton rows={pagination.pageSize} />1255 <DataTableEmptyBody>1256 <DataTableEmptyMessage>1257 <DataTableEmptyIcon>1258 <UserSearch className="size-12" />1259 </DataTableEmptyIcon>1260 <DataTableEmptyTitle>No products found</DataTableEmptyTitle>1261 <DataTableEmptyDescription>1262 There are no products to display at this time.1263 </DataTableEmptyDescription>1264 </DataTableEmptyMessage>1265 <DataTableEmptyFilteredMessage>1266 <DataTableEmptyIcon>1267 <SearchX className="size-12" />1268 </DataTableEmptyIcon>1269 <DataTableEmptyTitle>No matches found</DataTableEmptyTitle>1270 <DataTableEmptyDescription>1271 Try adjusting your filters or search to find what you're1272 looking for.1273 </DataTableEmptyDescription>1274 </DataTableEmptyFilteredMessage>1275 </DataTableEmptyBody>1276 </DataTableBody>1277 </DataTable>1278 <DataTablePagination1279 totalCount={totalCount}1280 isLoading={isLoading}1281 isFetching={isFetching}1282 />1283 </DataTableRoot>1284
1285 {/* Demo-only: shows the URL params and the query sent to the server */}1286 <Card>1287 <CardHeader>1288 <CardTitle>URL State & Server Query</CardTitle>1289 <CardDescription>1290 Every state slice lives in the URL — copy the address bar and the1291 exact view is shareable. The same serializable shape is what the1292 mock <code>fetchProducts</code> receives; swap it for a real API and1293 any database works.1294 </CardDescription>1295 <CardAction>1296 <Button variant="outline" size="sm" onClick={resetAllState}>1297 Reset All State1298 </Button>1299 </CardAction>1300 </CardHeader>1301 <CardContent className="space-y-3 text-xs">1302 <div className="flex justify-between">1303 <span className="font-medium">Status:</span>1304 <span>1305 {isLoading1306 ? "Initial load..."1307 : isFetching1308 ? "Fetching..."1309 : `${totalCount} rows on server, showing ${data.length}`}1310 </span>1311 </div>1312 <pre className="overflow-auto rounded bg-muted p-2">1313 {JSON.stringify(1314 {1315 page: pagination.pageIndex,1316 pageSize: pagination.pageSize,1317 sorting,1318 search: debouncedInput.search,1319 columnFilters: debouncedInput.columnFilters,1320 },1321 null,1322 2,1323 )}1324 </pre>1325 </CardContent>1326 </Card>1327 </div>1328 )1329}1330
1331/**1332 * Self-contained wrapper. In a real app create the QueryClient once at your1333 * app root and wrap the layout with QueryClientProvider + the NuqsAdapter1334 * for your framework instead (see the header comment).1335 */1336export default function ServerSideNuqsTableExample() {1337 const [queryClient] = useState(1338 () =>1339 new QueryClient({1340 defaultOptions: {1341 queries: {1342 staleTime: 30 * 1000,1343 refetchOnWindowFocus: false,1344 retry: 1,1345 },1346 },1347 }),1348 )1349
1350 return (1351 <QueryClientProvider client={queryClient}>1352 <NuqsAdapter>1353 <ServerSideNuqsTableContent />1354 </NuqsAdapter>1355 </QueryClientProvider>1356 )1357}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-column-faceted-filter @niko-table/data-table-column-slider-filter @niko-table/data-table-column-date-filterInstall additional dependencies:
pnpm add @tanstack/react-query nuqsThis example also uses checkbox from Shadcn UI:
pnpm dlx shadcn@latest add checkboxFirst time using
@niko-table? See the Installation Guide to set up the registry.
Wrap your app once, at the root, with both providers. Pick the nuqs adapter for your framework (adapter docs):
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query"2import { NuqsAdapter } from "nuqs/adapters/next/app"3// Pages Router: "nuqs/adapters/next/pages" — React SPA: "nuqs/adapters/react"4
5const queryClient = new QueryClient({6 defaultOptions: {7 queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false },8 },9})10
11export default function RootLayout({ children }) {12 return (13 <html>14 <body>15 <QueryClientProvider client={queryClient}>16 <NuqsAdapter>{children}</NuqsAdapter>17 </QueryClientProvider>18 </body>19 </html>20 )21}The example bundles its own providers so it stays copy-paste runnable — remove that wrapper once your root provides them.
URL state parsers
Section titled “URL state parsers”One parser per state slice; the keys double as the URL param names:
1import { parseAsInteger, parseAsJson, parseAsString, useQueryStates } from "nuqs"2
3const tableStateParsers = {4 page: parseAsInteger.withDefault(0),5 perPage: parseAsInteger.withDefault(10),6 sort: parseAsJson<SortingState>(v => v as SortingState).withDefault([]),7 // mixed-shape columnFilters (widget values + advanced-menu filter objects)8 filters: parseAsJson<ColumnFiltersState>(v => v as ColumnFiltersState).withDefault([]),9 search: parseAsString.withDefault(""),10 // OR/MIXED advanced filters — an object, never a string11 global: parseAsJson<GlobalFilterObject | null>(v =>12 v && typeof v === "object" && "filters" in v ? (v as GlobalFilterObject) : null,13 ).withDefault(null),14 cols: parseAsJson<VisibilityState>(v => v as VisibilityState).withDefault({}),15}16
17const [urlParams, setUrlParams] = useQueryStates(tableStateParsers, {18 history: "replace", // don't spam the back button19 scroll: false,20 shallow: true,21})A filtered view produces a URL like:
1?page=0&perPage=10&sort=[{"id":"price","desc":true}]&filters=[{"id":"category","value":["electronics"]}]&search=proURL → table state, table state → URL
Section titled “URL → table state, table state → URL”Derive TanStack state from the URL and write updates back in the handlers. Every filter/sort change also resets page to 0:
1// URL → state2const pagination = useMemo(3 () => ({ pageIndex: urlParams.page, pageSize: urlParams.perPage }),4 [urlParams.page, urlParams.perPage],5)6const sorting = urlParams.sort7const columnFilters = urlParams.filters8// search string and the OR/MIXED filter object share the globalFilter slot9const globalFilter = urlParams.global ?? urlParams.search10
11// state → URL12const handleSortingChange = (updater: Updater<SortingState>) => {13 const next = typeof updater === "function" ? updater(sorting) : updater14 void setUrlParams({ sort: next.length > 0 ? next : null, page: 0 })15}Passing null removes a param from the URL, keeping default states clean.
Serializing filters for the URL
Section titled “Serializing filters for the URL”Two details keep the URLs short and stable:
filterIdis stripped on write (serializeFiltersForUrl) and regenerated on read (normalizeFiltersFromUrl) — both exported fromfilters/table-filter-menu. The ids are internal to the menu UI; regenerating them index-based keeps input focus stable.- Widget values pass through untouched — faceted
string[], slider[min, max]tuples, and booleans are already URL-friendly JSON.
The advanced filter menu is controlled from the URL exactly like the non-nuqs example routes it from useState: AND rules live in the filters param, OR/MIXED rules move to the global param. See handleMenuFiltersChange in the example.
Fetching
Section titled “Fetching”Identical to the Server-Side Table: the URL-derived state feeds the query key, so a shared link resolves to the same cache entry.
1const { data } = useQuery({2 queryKey: ["products", pagination.pageIndex, pagination.pageSize, sorting, globalFilter, debouncedColumnFilters],3 queryFn: () => fetchProducts({ ... }),4 placeholderData: keepPreviousData,5})Because the request shape is already serialized in the URL, a server-rendered page can run the same query during SSR from the incoming search params — no client round trip for the first paint.
When to Use
Section titled “When to Use”✅ Use Server-Side Nuqs Table when:
- Everything from the Server-Side Table applies, and
- Table views must be shareable, bookmarkable, or survive refreshes
- You want back/forward to step through filter states — switch the snippet to
history: "push"for that
❌ Consider other options when:
- URL noise matters more than shareability — use Server-Side Table
- Data fits client-side — use Advanced Nuqs Table
Next Steps
Section titled “Next Steps”- Server-Side Table: the wire contract, filter shapes, facets, and manual modes in depth
- Drizzle ORM + Nuqs: same URL layer over a real Drizzle SQL backend
- Server-Side Grid: the editable Data Grid against a server
- Advanced Nuqs Table: client-side filtering with URL state
- nuqs Docs