Production data tables with TanStack Table v8, shadcn/ui, URL-synced state, and server-side operations. Use when building admin dashboards, list views, CRM tables, or any sortable/filterable/paginated data grid. Integrates with product-stack API shapes and React Query hooks.
install
npx skills add nabinkhair42/nk-skills --skill data-table-pattern
Data Table Pattern
The standard for SaaS list views in 2026: TanStack Table v8 for headless logic, shadcn/ui for styling, URL state for shareable views, and server-side operations for real datasets.
Pairs with product-stack (API routes, hooks, PaginatedData<T>) and optimistic-cache-pattern (instant row updates, cache sync on mutations).
File Structure
src/components/
├── ui/
│ └── data-table/
│ ├── data-table.tsx # Main orchestrator
│ ├── data-table-column-header.tsx
│ ├── data-table-faceted-filter.tsx
│ ├── data-table-pagination.tsx
│ ├── data-table-toolbar.tsx
│ └── data-table-bulk-actions.tsx
└── {resource}/
├── {resource}-columns.tsx # ColumnDef per resource
└── {resource}-table.tsx # Feature table wrapper
One columns.tsx per resource. Never define columns inline in page files.
Column Definitions
// components/projects/project-columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import type { Project } from "@/db/schema/projects";
export const projectColumns: ColumnDef<Project>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.getValue("status") as string;
return <Badge variant="outline">{status}</Badge>;
},
filterFn: (row, id, value: string[]) =>
value.includes(row.getValue(id)),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date;
return date.toLocaleDateString();
},
},
];
Rules:
- Always use
DataTableColumnHeaderfor sortable columns filterFnfor faceted filters — return booleansizeon select/actions columns to prevent layout shift- Row actions go in a final column with
enableSorting: false
URL-Synced Table State
Sync sort, filter, pagination to URL so views are shareable and survive refresh.
// hooks/use-table-url-state.ts
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useCallback, useMemo } from "react";
import type { SortingState, ColumnFiltersState } from "@tanstack/react-table";
export function useTableUrlState() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const page = Number(searchParams.get("page") ?? "1");
const limit = Number(searchParams.get("limit") ?? "10");
const sort = searchParams.get("sort") ?? "createdAt";
const order = (searchParams.get("order") ?? "desc") as "asc" | "desc";
const sorting: SortingState = useMemo(
() => [{ id: sort, desc: order === "desc" }],
[sort, order],
);
const statusFilter = searchParams.get("status")?.split(",").filter(Boolean) ?? [];
const columnFilters: ColumnFiltersState = useMemo(
() => (statusFilter.length ? [{ id: "status", value: statusFilter }] : []),
[statusFilter],
);
const setParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === "") params.delete(key);
else params.set(key, value);
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[searchParams, router, pathname],
);
return { page, limit, sorting, columnFilters, setParams };
}
Rules:
router.replacenotpush— avoid polluting browser history on every filter change{ scroll: false }— table filters should not scroll the page- Reset
pageto1when sort or filters change - Comma-separate multi-value filters:
?status=active,draft
Feature Table Wrapper
// components/projects/project-table.tsx
"use client";
import { useProjects } from "@/hooks/use-projects";
import { useTableUrlState } from "@/hooks/use-table-url-state";
import { DataTable } from "@/components/ui/data-table/data-table";
import { projectColumns } from "./project-columns";
import { DataTableToolbar } from "@/components/ui/data-table/data-table-toolbar";
const STATUS_OPTIONS = [
{ label: "Active", value: "active" },
{ label: "Draft", value: "draft" },
{ label: "Archived", value: "archived" },
];
export function ProjectTable() {
const { page, limit, sorting, columnFilters, setParams } = useTableUrlState();
const sort = sorting[0]?.id ?? "createdAt";
const order = sorting[0]?.desc ? "desc" : "asc";
const status = (columnFilters.find((f) => f.id === "status")?.value as string[]) ?? [];
const { data, isPending } = useProjects({ page, limit, sort, order, status });
return (
<DataTable
columns={projectColumns}
data={data?.data.items ?? []}
pageCount={data?.data.meta.totalPages ?? 0}
rowCount={data?.data.meta.total ?? 0}
isLoading={isPending}
sorting={sorting}
columnFilters={columnFilters}
onSortingChange={(updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater;
const col = next[0];
setParams({
sort: col?.id ?? "createdAt",
order: col?.desc ? "desc" : "asc",
page: "1",
});
}}
onColumnFiltersChange={(updater) => {
const next = typeof updater === "function" ? updater(columnFilters) : updater;
const statusVal = next.find((f) => f.id === "status")?.value as string[] | undefined;
setParams({
status: statusVal?.length ? statusVal.join(",") : null,
page: "1",
});
}}
onPaginationChange={(pageIndex, pageSize) => {
setParams({ page: String(pageIndex + 1), limit: String(pageSize) });
}}
toolbar={
<DataTableToolbar
filterColumn="status"
filterOptions={STATUS_OPTIONS}
searchPlaceholder="Filter projects..."
/>
}
/>
);
}
Server-Side API (product-stack integration)
Extend route handlers to accept table query params:
// app/api/projects/route.ts — GET additions
const sort = searchParams.get("sort") ?? "createdAt";
const order = searchParams.get("order") ?? "desc";
const status = searchParams.get("status")?.split(",").filter(Boolean) ?? [];
const sortColumn = projects[sort as keyof typeof projects] ?? projects.createdAt;
const orderFn = order === "asc" ? asc : desc;
let query = db
.select()
.from(projects)
.where(eq(projects.userId, user.id));
if (status.length) {
query = query.where(inArray(projects.status, status));
}
const rows = await query
.orderBy(orderFn(sortColumn))
.limit(limit)
.offset(offset);
Extend the hook to pass table params:
// hooks/use-projects.ts
export function useProjects(params: {
page?: number;
limit?: number;
sort?: string;
order?: "asc" | "desc";
status?: string[];
enabled?: boolean;
} = {}) {
const { page = 1, limit = 10, sort, order, status, enabled = true } = params;
return useQuery({
queryKey: [...QUERY_KEYS.PROJECTS, { page, limit, sort, order, status }],
queryFn: () => projectService.getAll({ page, limit, sort, order, status }),
enabled,
});
}
Bulk Actions
Show toolbar only when rows are selected:
// components/ui/data-table/data-table-bulk-actions.tsx
interface BulkActionToolbarProps {
selectedCount: number;
onClear: () => void;
children: React.ReactNode;
}
export function BulkActionToolbar({ selectedCount, onClear, children }: BulkActionToolbarProps) {
if (selectedCount === 0) return null;
return (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-2">
<span className="text-sm text-muted-foreground">
{selectedCount} selected
</span>
{children}
<Button variant="ghost" size="sm" onClick={onClear}>
Clear
</Button>
</div>
);
}
Clear row selection when data changes (after delete mutation):
useEffect(() => {
table.resetRowSelection();
}, [data, table]);
Virtualization (100+ rows)
For client-side tables or when server returns large pages:
import { useVirtualizer } from "@tanstack/react-virtual";
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48,
overscan: 10,
});
When to virtualize:
| Row count | Strategy |
|---|---|
| < 100 | No virtualization |
| 100–1,000 | Client virtualization |
| 1,000+ | Server-side pagination + virtualization |
Mutation Cache Sync
After bulk delete or inline edit, update cache without refetch (from optimistic-cache-pattern):
onSuccess: (_, deletedIds: string[]) => {
queryClient.setQueriesData({ queryKey: QUERY_KEYS.PROJECTS }, (old) => {
if (!old?.data) return old;
return {
...old,
data: {
...old.data,
items: old.data.items.filter((p) => !deletedIds.includes(p.id)),
meta: { ...old.data.meta, total: old.data.meta.total - deletedIds.length },
},
};
});
};
Adding a New Table Checklist
- Columns —
components/{resource}/{resource}-columns.tsx - Table wrapper —
components/{resource}/{resource}-table.tsx - API params — extend GET route handler with sort/filter/pagination
- Service — pass query params to axios
params - Hook — include params in
queryKeyandqueryFn - Page — render
<ResourceTable />in dashboard page
Common Mistakes
- Client-side sort on server data — sort on the server when dataset exceeds one page
- Missing params in queryKey — stale data when filters change; every param must be in the key
router.pushfor filters — pollutes history; usereplace- Inline column definitions — hard to test and reuse; always separate file
- No loading skeleton — show skeleton rows matching column layout, not a spinner
- Bulk delete without cache update — triggers unnecessary refetch; use
setQueriesData - 4-column grid on mobile — wrap in
overflow-x-autowithmin-w-[600px]inner container