React Data Tables: TanStack Table v8, Server-Side Sorting, Filtering, and Pagination
Build production data tables in React with TanStack Table v8. Covers server-side sorting, filtering, and cursor pagination, column visibility, row selection with bulk actions, column pinning, and virtualized rows with TanStack Virtual.
TanStack Table v8 is headless β it handles all the logic (sorting, filtering, pagination, selection) but provides zero UI. You own the markup and styling. This sounds like more work, but it means the table looks like your design system, not a library's opinionated components.
This guide covers the full server-side pattern: URL-synchronized state, server-side sort/filter/paginate, row selection, and bulk actions.
Column Definitions
// components/invoices/columns.tsx
import { createColumnHelper } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/format";
import { InvoiceActionsMenu } from "./invoice-actions-menu";
export interface InvoiceRow {
id: string;
number: string;
clientName: string;
amountCents: number;
status: "draft" | "sent" | "paid" | "overdue";
dueDate: string;
createdAt: string;
}
const STATUS_BADGE: Record<InvoiceRow["status"], { label: string; variant: string }> = {
draft: { label: "Draft", variant: "secondary" },
sent: { label: "Sent", variant: "info" },
paid: { label: "Paid", variant: "success" },
overdue: { label: "Overdue", variant: "destructive" },
};
const col = createColumnHelper<InvoiceRow>();
export const invoiceColumns = [
// Row selection checkbox
col.display({
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
aria-label="Select all"
className="w-4 h-4 rounded"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
aria-label={`Select invoice ${row.original.number}`}
className="w-4 h-4 rounded"
/>
),
size: 40,
enableSorting: false,
}),
col.accessor("number", {
header: "Invoice",
cell: (info) => <span className="font-mono text-sm">{info.getValue()}</span>,
enableSorting: true,
}),
col.accessor("clientName", {
header: "Client",
enableSorting: true,
}),
col.accessor("amountCents", {
header: "Amount",
cell: (info) => formatCurrency(info.getValue()),
enableSorting: true,
meta: { align: "right" },
}),
col.accessor("status", {
header: "Status",
enableSorting: true,
cell: (info) => {
const config = STATUS_BADGE[info.getValue()];
return <Badge variant={config.variant as any}>{config.label}</Badge>;
},
}),
col.accessor("dueDate", {
header: "Due Date",
cell: (info) => formatDate(info.getValue()),
enableSorting: true,
}),
// Actions column (no sorting)
col.display({
id: "actions",
header: "",
cell: ({ row }) => <InvoiceActionsMenu invoice={row.original} />,
enableSorting: false,
size: 60,
}),
];
URL-Synchronized Table State
// hooks/use-table-params.ts
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useMemo } from "react";
import type { SortingState, ColumnFiltersState } from "@tanstack/react-table";
export interface TableParams {
sorting: SortingState;
filters: Record<string, string>;
cursor?: string;
pageSize: number;
}
export function useTableParams(): {
params: TableParams;
setSorting: (sorting: SortingState) => void;
setFilter: (column: string, value: string) => void;
setCursor: (cursor: string | undefined) => void;
} {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const params = useMemo((): TableParams => {
const sortBy = searchParams.get("sortBy");
const sortDir = searchParams.get("sortDir") ?? "asc";
const cursor = searchParams.get("cursor") ?? undefined;
const pageSize = parseInt(searchParams.get("pageSize") ?? "25");
return {
sorting: sortBy ? [{ id: sortBy, desc: sortDir === "desc" }] : [],
filters: Object.fromEntries(
Array.from(searchParams.entries()).filter(([k]) =>
!["sortBy", "sortDir", "cursor", "pageSize"].includes(k)
)
),
cursor,
pageSize,
};
}, [searchParams]);
function updateParams(updates: Partial<Record<string, string | undefined>>) {
const current = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([k, v]) => {
if (v === undefined) current.delete(k);
else current.set(k, v);
});
// Reset cursor when sorting/filtering changes
router.push(`${pathname}?${current.toString()}`);
}
const setSorting = useCallback((sorting: SortingState) => {
const s = sorting[0];
updateParams({
sortBy: s ? s.id : undefined,
sortDir: s ? (s.desc ? "desc" : "asc") : undefined,
cursor: undefined, // Reset pagination
});
}, [searchParams]);
const setFilter = useCallback((column: string, value: string) => {
updateParams({ [column]: value || undefined, cursor: undefined });
}, [searchParams]);
const setCursor = useCallback((cursor: string | undefined) => {
updateParams({ cursor });
}, [searchParams]);
return { params, setSorting, setFilter, setCursor };
}
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
InvoicesTable Component
// components/invoices/invoices-table.tsx
"use client";
import {
useReactTable,
getCoreRowModel,
flexRender,
type SortingState,
type RowSelectionState,
} from "@tanstack/react-table";
import { useState, useCallback } from "react";
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import { invoiceColumns, type InvoiceRow } from "./columns";
import { useTableParams } from "@/hooks/use-table-params";
import { BulkActionBar } from "./bulk-action-bar";
interface InvoicesTableProps {
data: InvoiceRow[];
totalCount: number;
nextCursor?: string;
prevCursor?: string;
}
export function InvoicesTable({
data,
totalCount,
nextCursor,
prevCursor,
}: InvoicesTableProps) {
const { params, setSorting, setFilter, setCursor } = useTableParams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [columnVisibility, setColumnVisibility] = useState({});
const table = useReactTable({
data,
columns: invoiceColumns,
state: {
sorting: params.sorting,
rowSelection,
columnVisibility,
},
// Server-side: tell TanStack Table we control sorting/filtering externally
manualSorting: true,
manualFiltering: true,
manualPagination: true,
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(params.sorting) : updater;
setSorting(next);
},
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
});
const selectedIds = Object.keys(rowSelection);
return (
<div className="space-y-4">
{/* Filters bar */}
<div className="flex items-center gap-3">
<input
type="search"
placeholder="Search clientsβ¦"
defaultValue={params.filters.clientName ?? ""}
onChange={(e) => setFilter("clientName", e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<select
value={params.filters.status ?? ""}
onChange={(e) => setFilter("status", e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
{/* Column visibility toggle */}
<div className="relative ml-auto">
<button className="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">
Columns βΎ
</button>
</div>
<span className="text-sm text-gray-500">{totalCount} invoices</span>
</div>
{/* Bulk action bar β appears when rows selected */}
{selectedIds.length > 0 && (
<BulkActionBar
selectedIds={selectedIds}
onClear={() => setRowSelection({})}
/>
)}
{/* Table */}
<div className="border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide"
style={{ width: header.getSize() }}
>
{header.column.getCanSort() ? (
<button
onClick={header.column.getToggleSortingHandler()}
className="flex items-center gap-1 hover:text-gray-900"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" && <ChevronUp className="w-3.5 h-3.5" />}
{header.column.getIsSorted() === "desc" && <ChevronDown className="w-3.5 h-3.5" />}
{!header.column.getIsSorted() && <ChevronsUpDown className="w-3.5 h-3.5 text-gray-300" />}
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-100">
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={`hover:bg-gray-50 transition-colors ${
row.getIsSelected() ? "bg-blue-50" : ""
}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-gray-900">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
{data.length === 0 && (
<tr>
<td
colSpan={table.getAllColumns().length}
className="px-4 py-12 text-center text-sm text-gray-400"
>
No invoices found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Cursor pagination */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">
{selectedIds.length > 0
? `${selectedIds.length} selected`
: `Showing ${data.length} of ${totalCount}`}
</span>
<div className="flex gap-2">
<button
onClick={() => setCursor(prevCursor)}
disabled={!prevCursor}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
β Previous
</button>
<button
onClick={() => setCursor(nextCursor)}
disabled={!nextCursor}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
Next β
</button>
</div>
</div>
</div>
);
}
Server-Side Data Fetching
// app/invoices/page.tsx
import { InvoicesTable } from "@/components/invoices/invoices-table";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import type { Prisma } from "@prisma/client";
interface PageProps {
searchParams: {
sortBy?: string;
sortDir?: "asc" | "desc";
cursor?: string;
clientName?: string;
status?: string;
};
}
const VALID_SORT_COLUMNS = ["number", "clientName", "amountCents", "status", "dueDate"] as const;
type SortColumn = typeof VALID_SORT_COLUMNS[number];
export default async function InvoicesPage({ searchParams }: PageProps) {
const session = await auth();
const PAGE_SIZE = 25;
// Validate sort column (prevent SQL injection via orderBy)
const sortBy = VALID_SORT_COLUMNS.includes(searchParams.sortBy as SortColumn)
? (searchParams.sortBy as SortColumn)
: "createdAt";
const sortDir = searchParams.sortDir === "desc" ? "desc" : "asc";
const where: Prisma.InvoiceWhereInput = {
workspaceId: session!.user.workspaceId,
...(searchParams.clientName && {
clientName: { contains: searchParams.clientName, mode: "insensitive" },
}),
...(searchParams.status && { status: searchParams.status }),
};
const [invoices, totalCount] = await Promise.all([
prisma.invoice.findMany({
where,
orderBy: { [sortBy]: sortDir },
take: PAGE_SIZE + 1, // Fetch one extra to check if next page exists
cursor: searchParams.cursor ? { id: searchParams.cursor } : undefined,
skip: searchParams.cursor ? 1 : 0,
select: {
id: true, number: true, clientName: true,
amountCents: true, status: true, dueDate: true, createdAt: true,
},
}),
prisma.invoice.count({ where }),
]);
const hasNextPage = invoices.length > PAGE_SIZE;
const page = hasNextPage ? invoices.slice(0, PAGE_SIZE) : invoices;
const nextCursor = hasNextPage ? page[page.length - 1].id : undefined;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Invoices</h1>
<InvoicesTable
data={page}
totalCount={totalCount}
nextCursor={nextCursor}
/>
</div>
);
}
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic table with client-side sort | 1 dev | 1β2 days | $400β800 |
| Server-side sort + filter + cursor pagination | 1β2 devs | 3β4 days | $1,200β2,400 |
| + Row selection + bulk actions + column visibility | 1 dev | + 2 days | $600β1,200 |
See Also
- React TanStack Table Patterns
- React Virtualized Infinite Scroll
- PostgreSQL Cursor Pagination
- Next.js Server Components Data Fetching
- React Optimistic Updates
Working With Viprasol
Data tables are deceptively complex β server-side sort/filter/paginate with URL synchronization, bulk actions that don't lose selection state on refresh, and column visibility that persists per user. Our team builds TanStack Table v8 integrations with headless logic, your design system's markup, cursor-based pagination, and full URL synchronization so users can share filtered table views.
What we deliver:
createColumnHelpercolumn definitions with select checkbox, sortable accessors, action menuuseTableParamshook: URL-synchronized sorting, filters, and cursor fromuseSearchParamsInvoicesTable:manualSorting/Filtering/Pagination,onSortingChange, row selection state- Sort indicators:
ChevronUp/ChevronDown/ChevronsUpDownper column - Server page: Prisma cursor pagination,
VALID_SORT_COLUMNSwhitelist, parallel count query BulkActionBarwithselectedIdsand clear selection
Talk to our team about your data table requirements β
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.