Skip to content

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.

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.

Open in
Product Name
Category
Brand
Price
Stock
Rating
In Stock
Release Date
URL State & Server Query
Every state slice lives in the URL — copy the address bar and the exact view is shareable. The same serializable shape is what the mock fetchProducts receives; swap it for a real API and any database works.
Status:Initial load...
{
  "page": 0,
  "pageSize": 10,
  "sorting": [],
  "search": "",
  "columnFilters": []
}

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-filter

Install additional dependencies:

pnpm add @tanstack/react-query nuqs

This example also uses checkbox from Shadcn UI:

pnpm dlx shadcn@latest add checkbox

First 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):

app/layout.tsx (Next.js App Router)
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { NuqsAdapter } from "nuqs/adapters/next/app"
// Pages Router: "nuqs/adapters/next/pages" — React SPA: "nuqs/adapters/react"
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false },
},
})
export default function RootLayout({ children }) {
return (
<html>
<body>
<QueryClientProvider client={queryClient}>
<NuqsAdapter>{children}</NuqsAdapter>
</QueryClientProvider>
</body>
</html>
)
}

The example bundles its own providers so it stays copy-paste runnable — remove that wrapper once your root provides them.

One parser per state slice; the keys double as the URL param names:

import { parseAsInteger, parseAsJson, parseAsString, useQueryStates } from "nuqs"
const tableStateParsers = {
page: parseAsInteger.withDefault(0),
perPage: parseAsInteger.withDefault(10),
sort: parseAsJson<SortingState>(v => v as SortingState).withDefault([]),
// mixed-shape columnFilters (widget values + advanced-menu filter objects)
filters: parseAsJson<ColumnFiltersState>(v => v as ColumnFiltersState).withDefault([]),
search: parseAsString.withDefault(""),
// OR/MIXED advanced filters — an object, never a string
global: parseAsJson<GlobalFilterObject | null>(v =>
v && typeof v === "object" && "filters" in v ? (v as GlobalFilterObject) : null,
).withDefault(null),
cols: parseAsJson<VisibilityState>(v => v as VisibilityState).withDefault({}),
}
const [urlParams, setUrlParams] = useQueryStates(tableStateParsers, {
history: "replace", // don't spam the back button
scroll: false,
shallow: true,
})

A filtered view produces a URL like:

?page=0&perPage=10&sort=[{"id":"price","desc":true}]&filters=[{"id":"category","value":["electronics"]}]&search=pro

Derive TanStack state from the URL and write updates back in the handlers. Every filter/sort change also resets page to 0:

// URL → state
const pagination = useMemo(
() => ({ pageIndex: urlParams.page, pageSize: urlParams.perPage }),
[urlParams.page, urlParams.perPage],
)
const sorting = urlParams.sort
const columnFilters = urlParams.filters
// search string and the OR/MIXED filter object share the globalFilter slot
const globalFilter = urlParams.global ?? urlParams.search
// state → URL
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === "function" ? updater(sorting) : updater
void setUrlParams({ sort: next.length > 0 ? next : null, page: 0 })
}

Passing null removes a param from the URL, keeping default states clean.

Two details keep the URLs short and stable:

  • filterId is stripped on write (serializeFiltersForUrl) and regenerated on read (normalizeFiltersFromUrl) — both exported from filters/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.

Identical to the Server-Side Table: the URL-derived state feeds the query key, so a shared link resolves to the same cache entry.

const { data } = useQuery({
queryKey: ["products", pagination.pageIndex, pagination.pageSize, sorting, globalFilter, debouncedColumnFilters],
queryFn: () => fetchProducts({ ... }),
placeholderData: keepPreviousData,
})

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.

✅ 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: