TanStack Table is the gold standard for headless data tables in React. It gives you 100% control over markup and styling while handling all the complex logic — sorting, filtering, pagination, virtualization — out of the box. This tutorial builds a fully-featured invoice dashboard you can adapt to any real project.
What You'll Build
An Invoice Dashboard application featuring:
- Sortable columns (ascending/descending)
- Column-level and global search filters
- Server-friendly pagination
- Multi-row selection with bulk actions
- Column visibility toggle
- CSV export
- Full TypeScript type safety
- Styled with Tailwind CSS (no third-party table UI library needed)
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- Solid understanding of React and TypeScript
- Familiarity with Next.js App Router
- Basic knowledge of Tailwind CSS
- A code editor (VS Code recommended)
Why TanStack Table?
Most table libraries ship with rigid components that force you into their visual system. TanStack Table takes the opposite approach — it is a headless utility that manages state and logic but renders nothing. You write the <table>, <thead>, and <tbody> yourself, which means:
- Zero style conflicts with your design system
- Complete accessibility control
- Small bundle (under 15 KB gzipped)
- Works with any UI library: Tailwind, shadcn/ui, Radix, Material UI
Step 1: Project Setup
Create a new Next.js 15 project:
npx create-next-app@latest invoice-dashboard --typescript --tailwind --eslint --app
cd invoice-dashboardInstall TanStack Table:
npm install @tanstack/react-tableYour initial file structure:
invoice-dashboard/
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── data-table/
├── lib/
│ └── data.ts
└── types/
└── invoice.ts
Step 2: Define Types and Mock Data
Create the invoice type:
// types/invoice.ts
export type InvoiceStatus = "paid" | "pending" | "overdue";
export interface Invoice {
id: string;
invoiceNumber: string;
client: string;
email: string;
amount: number;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
}Create mock data to populate the table:
// lib/data.ts
import { Invoice } from "@/types/invoice";
export const invoices: Invoice[] = [
{
id: "1",
invoiceNumber: "INV-001",
client: "Acme Corp",
email: "billing@acme.com",
amount: 4200,
status: "paid",
issueDate: "2026-01-10",
dueDate: "2026-02-10",
},
{
id: "2",
invoiceNumber: "INV-002",
client: "Globex Inc",
email: "accounts@globex.com",
amount: 8750,
status: "pending",
issueDate: "2026-01-15",
dueDate: "2026-02-15",
},
{
id: "3",
invoiceNumber: "INV-003",
client: "Initech",
email: "finance@initech.io",
amount: 1350,
status: "overdue",
issueDate: "2025-12-01",
dueDate: "2026-01-01",
},
// Add 20+ more entries for meaningful pagination demos
{
id: "4",
invoiceNumber: "INV-004",
client: "Umbrella Ltd",
email: "pay@umbrella.com",
amount: 12000,
status: "paid",
issueDate: "2026-02-01",
dueDate: "2026-03-01",
},
{
id: "5",
invoiceNumber: "INV-005",
client: "Stark Industries",
email: "ar@stark.io",
amount: 55000,
status: "pending",
issueDate: "2026-02-10",
dueDate: "2026-03-10",
},
];Step 3: Define Column Configurations
Column definitions are the heart of TanStack Table. Each column describes how to access data, how to render it, and which features apply:
// components/data-table/columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Invoice, InvoiceStatus } from "@/types/invoice";
import { ArrowUpDown } from "lucide-react";
const statusStyles: Record<InvoiceStatus, string> = {
paid: "bg-green-100 text-green-800",
pending: "bg-yellow-100 text-yellow-800",
overdue: "bg-red-100 text-red-800",
};
export const columns: ColumnDef<Invoice>[] = [
// Selection checkbox column
{
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
className="h-4 w-4 rounded border-gray-300"
/>
),
enableSorting: false,
enableHiding: false,
},
// Invoice number with sorting
{
accessorKey: "invoiceNumber",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
Invoice
<ArrowUpDown className="h-4 w-4" />
</button>
),
},
{
accessorKey: "client",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
Client
<ArrowUpDown className="h-4 w-4" />
</button>
),
},
{
accessorKey: "email",
header: "Email",
// This column is hidden by default in narrow viewports
enableHiding: true,
},
{
accessorKey: "amount",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
Amount
<ArrowUpDown className="h-4 w-4" />
</button>
),
cell: ({ row }) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(row.getValue("amount")),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as InvoiceStatus;
return (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${statusStyles[status]}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
filterFn: "equalsString",
},
{
accessorKey: "dueDate",
header: "Due Date",
cell: ({ row }) =>
new Date(row.getValue("dueDate")).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}),
},
];Step 4: Build the Core Table Component
This is where TanStack Table comes to life. The useReactTable hook wires all features together:
// components/data-table/DataTable.tsx
"use client";
import { useState } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
SortingState,
ColumnFiltersState,
VisibilityState,
RowSelectionState,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { Invoice } from "@/types/invoice";
interface DataTableProps {
data: Invoice[];
}
export function DataTable({ data }: DataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: { pageSize: 10 },
},
});
return (
<div className="space-y-4">
{/* Toolbar */}
<TableToolbar
table={table}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
data={data}
/>
{/* Table */}
<div className="rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={`hover:bg-gray-50 transition-colors ${
row.getIsSelected() ? "bg-indigo-50" : ""
}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 whitespace-nowrap">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-gray-500"
>
No invoices found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<TablePagination table={table} />
</div>
);
}Step 5: Add the Toolbar
The toolbar contains the global search, column visibility toggle, and bulk-action buttons:
// components/data-table/TableToolbar.tsx
"use client";
import { Table } from "@tanstack/react-table";
import { Invoice } from "@/types/invoice";
import { Search, Download, Eye } from "lucide-react";
import { exportToCSV } from "@/lib/export";
interface TableToolbarProps {
table: Table<Invoice>;
globalFilter: string;
onGlobalFilterChange: (value: string) => void;
data: Invoice[];
}
export function TableToolbar({
table,
globalFilter,
onGlobalFilterChange,
data,
}: TableToolbarProps) {
const selectedCount = table.getFilteredSelectedRowModel().rows.length;
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Global search */}
<div className="relative max-w-sm w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search all columns..."
value={globalFilter}
onChange={(e) => onGlobalFilterChange(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex items-center gap-2">
{/* Bulk action when rows are selected */}
{selectedCount > 0 && (
<span className="text-sm text-gray-600">
{selectedCount} row{selectedCount > 1 ? "s" : ""} selected
</span>
)}
{/* CSV export */}
<button
onClick={() => exportToCSV(data, "invoices")}
className="flex items-center gap-2 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Download className="h-4 w-4" />
Export
</button>
{/* Column visibility */}
<ColumnVisibilityToggle table={table} />
</div>
</div>
);
}
function ColumnVisibilityToggle({ table }: { table: Table<Invoice> }) {
return (
<div className="relative group">
<button className="flex items-center gap-2 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
<Eye className="h-4 w-4" />
Columns
</button>
<div className="absolute right-0 top-full mt-1 z-10 hidden group-hover:block bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-40">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<label
key={col.id}
className="flex items-center gap-2 px-2 py-1 text-sm cursor-pointer hover:bg-gray-50 rounded"
>
<input
type="checkbox"
checked={col.getIsVisible()}
onChange={col.getToggleVisibilityHandler()}
className="h-4 w-4"
/>
{col.id}
</label>
))}
</div>
</div>
);
}Step 6: Implement Pagination Controls
// components/data-table/TablePagination.tsx
"use client";
import { Table } from "@tanstack/react-table";
import { Invoice } from "@/types/invoice";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
interface TablePaginationProps {
table: Table<Invoice>;
}
export function TablePagination({ table }: TablePaginationProps) {
const { pageIndex, pageSize } = table.getState().pagination;
const totalRows = table.getFilteredRowModel().rows.length;
const from = pageIndex * pageSize + 1;
const to = Math.min((pageIndex + 1) * pageSize, totalRows);
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
<span>
Showing {from}–{to} of {totalRows} invoices
</span>
<div className="flex items-center gap-1">
{/* Page size selector */}
<select
value={pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
className="border border-gray-300 rounded px-2 py-1 text-sm mr-3"
>
{[5, 10, 20, 50].map((size) => (
<option key={size} value={size}>
{size} / page
</option>
))}
</select>
<button
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronsLeft className="h-4 w-4" />
</button>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="px-3">
Page {pageIndex + 1} of {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight className="h-4 w-4" />
</button>
<button
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronsRight className="h-4 w-4" />
</button>
</div>
</div>
);
}Step 7: Add CSV Export Utility
// lib/export.ts
import { Invoice } from "@/types/invoice";
export function exportToCSV(data: Invoice[], filename: string) {
const headers = [
"Invoice #",
"Client",
"Email",
"Amount",
"Status",
"Issue Date",
"Due Date",
];
const rows = data.map((inv) => [
inv.invoiceNumber,
inv.client,
inv.email,
inv.amount.toString(),
inv.status,
inv.issueDate,
inv.dueDate,
]);
const csvContent = [headers, ...rows]
.map((row) => row.map((cell) => `"${cell}"`).join(","))
.join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename}-${new Date().toISOString().split("T")[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
}Step 8: Column-Level Filtering
For filtering specific columns (e.g., filter by status), add filter inputs below headers:
// In DataTable.tsx, add a filter row beneath the header
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-4 py-3 text-left">
{flexRender(header.column.columnDef.header, header.getContext())}
{/* Column filter input */}
{header.column.getCanFilter() && (
<ColumnFilter column={header.column} />
)}
</th>
))}
</tr>
))}
</thead>The ColumnFilter component adapts its input based on the data type:
// components/data-table/ColumnFilter.tsx
"use client";
import { Column } from "@tanstack/react-table";
import { Invoice } from "@/types/invoice";
export function ColumnFilter({ column }: { column: Column<Invoice, unknown> }) {
const filterValue = column.getFilterValue();
// For status column, render a select dropdown
if (column.id === "status") {
return (
<select
value={(filterValue as string) ?? ""}
onChange={(e) => column.setFilterValue(e.target.value || undefined)}
className="mt-1 w-full text-xs border border-gray-300 rounded px-1 py-0.5"
>
<option value="">All</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
<option value="overdue">Overdue</option>
</select>
);
}
// Default text filter
return (
<input
type="text"
value={(filterValue as string) ?? ""}
onChange={(e) => column.setFilterValue(e.target.value || undefined)}
placeholder="Filter..."
className="mt-1 w-full text-xs border border-gray-300 rounded px-2 py-0.5 font-normal"
/>
);
}Step 9: Wire Everything Together in the Page
// app/page.tsx
import { DataTable } from "@/components/data-table/DataTable";
import { invoices } from "@/lib/data";
export default function InvoiceDashboard() {
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Invoices</h1>
<p className="mt-1 text-sm text-gray-500">
Manage and track all your client invoices.
</p>
</div>
<DataTable data={invoices} />
</main>
);
}Step 10: Advanced — Server-Side Pagination and Sorting
For large datasets (thousands of rows), move sorting and pagination to the server. Tell TanStack Table it is in manual mode:
const table = useReactTable({
data,
columns,
// Tell the table it does NOT own sorting/pagination
manualSorting: true,
manualPagination: true,
// Total row count from server
rowCount: totalCount,
state: { sorting, pagination },
onSortingChange: (updater) => {
// Sync to URL params for shareable links
const next = typeof updater === "function" ? updater(sorting) : updater;
setSorting(next);
router.push(`?sort=${next[0]?.id}&dir=${next[0]?.desc ? "desc" : "asc"}`);
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
});Then fetch data server-side using the sort/page params:
// app/api/invoices/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const page = Number(searchParams.get("page") ?? 1);
const pageSize = Number(searchParams.get("pageSize") ?? 10);
const sort = searchParams.get("sort") ?? "dueDate";
const dir = searchParams.get("dir") ?? "asc";
// Replace with your actual database query
const data = await db.invoice.findMany({
orderBy: { [sort]: dir },
skip: (page - 1) * pageSize,
take: pageSize,
});
const total = await db.invoice.count();
return NextResponse.json({ data, total });
}Testing Your Implementation
Run the dev server and verify each feature:
npm run devChecklist:
- Click column headers — rows should sort ascending then descending
- Type in the global search box — rows should filter across all columns
- Use the Status dropdown filter — only matching rows should appear
- Click checkboxes — selected rows should highlight in indigo
- Click the Columns button — toggle email column visibility
- Click Export — a CSV file should download
- Use page size selector and navigation arrows — pagination should work correctly
Troubleshooting
Table shows no rows after filtering:
Ensure getFilteredRowModel is passed to useReactTable. Without it, filters have no effect.
Sorting is not working:
Confirm getSortedRowModel is in the hook and the column header calls column.toggleSorting().
TypeScript errors on accessorKey:
The column definition must use ColumnDef<YourType> — make sure the generic matches your data type.
Pagination total is wrong in manual mode:
You must pass rowCount to useReactTable equal to the total server-side record count (not just the page size).
flexRender returns undefined:
Ensure your column cell property returns a valid React node, not undefined. Use a fallback like row.getValue("fieldName") ?? "—".
Next Steps
Now that you have a fully-featured data table, consider these extensions:
- Row expand — use
getExpandedRowModelto show detail panels below rows - Drag-and-drop column reorder — combine with
@dnd-kit/core - Virtual scrolling — use
@tanstack/react-virtualto render only visible rows, supporting millions of records - Persist state in URL — serialize sort/filter/page to
searchParamsfor shareable, bookmarkable table views - Right-click context menu — add per-row actions without cluttering the UI
For the full TanStack ecosystem, pair this table with TanStack Query to feed live data into your columns, and TanStack Form for in-line cell editing.
Conclusion
TanStack Table gives you a production-grade data table experience without locking you into a rigid component library. By owning the rendering layer, you can match any design system, implement custom cell renderers, and scale to server-side data sources — all with full TypeScript inference guiding every column definition.