writing/tutorial/2026/05
TutorialMay 14, 2026·32 min read

Building Advanced Data Tables with TanStack Table and Next.js 15

Master advanced data tables in React with TanStack Table v8. Learn sorting, filtering, pagination, row selection, and column visibility with full TypeScript support in Next.js 15.

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

Install TanStack Table:

npm install @tanstack/react-table

Your 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 dev

Checklist:

  • 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 getExpandedRowModel to show detail panels below rows
  • Drag-and-drop column reorder — combine with @dnd-kit/core
  • Virtual scrolling — use @tanstack/react-virtual to render only visible rows, supporting millions of records
  • Persist state in URL — serialize sort/filter/page to searchParams for 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.