Skip to content

Server-Side Table

Server-side pagination, sorting, and filtering behind a database-agnostic query contract, with TanStack Query for fetching and caching.

Hands pagination, sorting, and filtering off to your API and uses TanStack Query for caching, background revalidation, and request dedup. The table only ever talks to one function with a serializable request shape — so it works with any database or backend: SQL, Prisma, Drizzle, Supabase, REST, GraphQL. State lives in useState only; a refresh resets filters — use the Server-Side Nuqs Table if you also need URL persistence.

The demo runs against a mock server — an in-memory array of 2,000 products plus simulated latency — so every filter, sort, and page works out of the box with no database. Everything in its clearly-marked MOCK SERVER section is a stand-in for your backend.

Open in
Product Name
Category
Brand
Price
Stock
Rating
In Stock
Release Date
Server Query
The serializable request the table sends on every change — swap the mock fetchProducts for a real API that accepts this shape 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

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 with QueryClientProvider once, at the root:

app/layout.tsx (App Router) — same idea for Pages Router / SPA roots
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
export default function RootLayout({ children }) {
return (
<html>
<body>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</body>
</html>
)
}

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

This is the heart of the example. The table never talks to a database — it sends ONE serializable object to your API on every change:

the request your backend receives
type ProductQuery = {
page: number // → LIMIT / OFFSET
pageSize: number
sorting: SortingState // → ORDER BY, e.g. [{ id: "price", desc: true }]
search: string | object // → global text search (see OR logic below)
columnFilters: ColumnFiltersState // → WHERE clauses, one entry per column
}
type ProductQueryResult = {
data: Product[] // one page of rows
total: number // total rows matching the filters
facets: {
// optional: powers cross-filter facet narrowing (see below)
select: Record<string, Array<{ value: string; count: number }>>
range: Record<string, [number, number]>
}
}

Swapping the demo’s mock for a real backend is one function:

api.ts
async function fetchProducts(query: ProductQuery): Promise<ProductQueryResult> {
const res = await fetch("/api/products", {
method: "POST",
body: JSON.stringify(query),
})
if (!res.ok) throw new Error("Failed to fetch products")
return res.json()
}

Each entry is { id, value }. Two value shapes travel in the same array — dispatch on shape server-side:

Source Value shape
Faceted filter (multi-select) string[]
Slider / date-range filter [min, max] tuple (numbers / ms timestamps, null = open-ended)
Single date filter bare ms timestamp (number)
Boolean filter boolean
Text filter string
Advanced filter menu (AND-joined rules) ExtendedColumnFilter{ id, operator, value, joinOperator }

Translate operators to your query builder:

ILIKE / NOT_ILIKE → WHERE name ILIKE '%' || $1 || '%'
EQ / NEQ → WHERE brand = $1 / <> $1
GT / GTE / LT / LTE → WHERE price > $1 ...
IN / NOT_IN → WHERE category = ANY($1)
EMPTY / NOT_EMPTY → WHERE col IS NULL OR col = ''
[min, max] tuple → WHERE price BETWEEN $1 AND $2
search (string) → ILIKE across columns, or full-text search

TanStack combines columnFilters with AND only. When the advanced filter menu’s rules use OR (or mixed) join logic, Niko Table routes them through the globalFilter slot instead — so query.search is either a plain search string or { filters: ExtendedColumnFilter[], joinOperator: "or" | "mixed" }. The mock server in the example shows the exact dispatch.

Tell the table the server owns pagination, sorting, and filtering:

<DataTableRoot
data={data}
columns={columns}
isLoading={isLoading}
config={{
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: Math.ceil(totalCount / pagination.pageSize),
}}
state={{ pagination, sorting, columnFilters, globalFilter, columnVisibility }}
onPaginationChange={setPagination}
onSortingChange={handleSortingChange}
onColumnFiltersChange={handleColumnFiltersChange}
onGlobalFilterChange={handleGlobalFilterChange}
onColumnVisibilityChange={setColumnVisibility}
>

Pass totalCount to <DataTablePagination totalCount={totalCount} /> so the row count reflects the server, not the current page. Reset pageIndex to 0 in every filter/sort handler — the old page may not exist in the new result set.

const debouncedColumnFilters = useDebounce(columnFilters, 300)
const { data: queryData, isLoading, isFetching, error } = useQuery({
// every parameter that affects the result belongs in the key
queryKey: [
"products",
pagination.pageIndex,
pagination.pageSize,
sorting,
globalFilter,
debouncedColumnFilters,
],
queryFn: () =>
fetchProducts({
page: pagination.pageIndex,
pageSize: pagination.pageSize,
sorting,
search: globalFilter,
columnFilters: debouncedColumnFilters,
}),
placeholderData: keepPreviousData, // keep rows visible while refetching
})
  • keepPreviousData prevents UI jumps during pagination.
  • Debouncing columnFilters batches rapid faceted-filter clicks into a single request.
  • isLoading (initial load, show skeleton) vs isFetching (any request in flight, show a subtle spinner).

The killer feature for server-side filtering: the server reports, per facetable column, which values still exist under every other active filter — each column’s own filter excluded, so its unselected options stay visible. Selecting a brand narrows the category counts, like a shopping-site sidebar.

-- per facet column, with that column's own filter excluded:
SELECT category, COUNT(*) FROM products WHERE <other filters> GROUP BY category

Feed the facets back into the columns on every response:

const columns = useMemo(() => buildColumns(queryData?.facets), [queryData?.facets])
// inside buildColumns:
<DataTableColumnFacetedFilterMenu options={categoryOptsWithCounts} />
<DataTableColumnSliderFilterMenu range={facets?.range.price} />

Set autoOptions: false in the meta of faceted columns — the table only holds one page of rows, so client-side option generation would be wrong.

The DataTableFilterMenu is controlled so its rules can be routed to the right state slot (AND rules → columnFilters, OR/MIXED rules → globalFilter) while preserving the column-widget filters:

const menuFilters = useMemo(() => {
if (typeof globalFilter === "object" && globalFilter && "filters" in globalFilter) {
return normalizeFiltersFromUrl(globalFilter.filters)
}
return normalizeFiltersFromUrl(
columnFilters.map(cf => cf.value).filter(isMenuFilterValue),
)
}, [globalFilter, columnFilters])
<DataTableFilterMenu filters={menuFilters} onFiltersChange={handleMenuFiltersChange} />

See handleMenuFiltersChange in the example for the ~30-line router built on processFiltersForLogic.

TanStack Query surfaces errors with retry logic; render an error card with a manual refetch() button. The mock server fails every 20th page so you can see it in the demo.

const { error, refetch } = useQuery({ ..., retry: 1 })

✅ Use Server-Side Table when:

  • Working with large datasets (thousands+ rows)
  • Data lives behind an API / database
  • You want automatic caching and background updates
  • You don’t need URL state persistence

❌ Consider other options when:

  • Working with small datasets (< 1000 rows) — client-side filtering is simpler
  • You need shareable/bookmarkable views — use Server-Side Nuqs Table