Every data table or datagrid I've created has been unique. They all behave differently, have specific sorting and filtering requirements, and work with different data sources.
It doesn't make sense to combine all of these variations into a single component. If we do that, we'll lose the flexibility that headless UI provides.
So instead of a data-table component, I thought it would be more helpful to provide a guide on how to build your own.
We'll start with the basic <Table /> component and build a complex data table from scratch.
Tip: If you find yourself using the same table in multiple places in your app, you can always extract it into a reusable component.
Table of Contents
This guide will show you how to use TanStack Table and the <Table /> component to build your own custom data table. We'll cover the following topics:
Note: This guide uses TanStack Table v9. If you are upgrading an existing table from v8, the @tanstack/react-table/legacy entry point ships useLegacyTable and the old get*RowModel helpers so you can move over in stages.
Prerequisites
We are going to build a table to show recent payments. Here's what our data looks like:
I'm using a Next.js example here but this works for any other React framework.
data-table-features.ts declares which table features this table uses.
columns.tsx (client component) will contain our column definitions.
data-table.tsx (client component) will contain our <DataTable /> component.
page.tsx (server component) is where we'll fetch data and render our table.
Features
In v9 you declare the features your table uses up front, and anything you don't declare is tree-shaken out of your bundle. That declaration lives in its own file because both columns.tsx and data-table.tsx need to refer to its type.
We'll start with an empty set and add to it as the guide goes on. The core row model is always built, so a basic table needs no features at all.
app/payments/data-table-features.ts
import { tableFeatures } from "@tanstack/react-table";export const features = tableFeatures({});export type DataTableFeatures = typeof features;
Note: Every section below adds one feature to this file. Each one is opt-in — a table that never sorts never pays for the sorting code.
Basic Table
Let's start by building a basic table.
Column Definitions
First, we'll define our columns. Column definitions take the features type as their first type argument, so the table knows which per-column options are available.
app/payments/columns.tsx
"use client";import type { ColumnDef } from "@tanstack/react-table";import type { DataTableFeatures } from "./data-table-features";// This type is used to define the shape of our data.// You can use a Zod schema here if you want.export type Payment = { id: string; amount: number; status: "pending" | "processing" | "success" | "failed"; email: string;};export const columns: ColumnDef<DataTableFeatures, Payment>[] = [ { accessorKey: "status", header: "Status", }, { accessorKey: "email", header: "Email", }, { accessorKey: "amount", header: "Amount", },];
Note: Columns are where you define the core of what your table
will look like. They define the data that will be displayed, how it will be
formatted, sorted and filtered.
<DataTable /> component
Next, we'll create a <DataTable /> component to render our table.
Headers and cells render through <table.FlexRender />, which replaces v8's standalone flexRender call.
Note: We use row.getAllCells() here because column visibility isn't registered yet. Once you add it in the Visibility section, switch to row.getVisibleCells() so hidden columns drop out.
Tip: If you find yourself using <DataTable /> in multiple places, this is the component you could make reusable by extracting it to components/ui/data-table.tsx.
<DataTable columns={columns} data={data} />
Render the table
Finally, we'll render our table in our page component.
app/payments/page.tsx
import { Payment, columns } from "./columns";import { DataTable } from "./data-table";async function getData(): Promise<Payment[]> { // Fetch data from your API here. return [ { id: "728ed52f", amount: 100, status: "pending", email: "m@example.com", }, // ... ];}export default async function DemoPage() { const data = await getData(); return ( <div className="container mx-auto py-10"> <DataTable columns={columns} data={data} /> </div> );}
Cell Formatting
Let's format the amount cell to display the dollar amount. We'll also align the cell to the right.
Update columns definition
Update the header and cell definitions for amount as follows:
You can access the row data using row.original in the cell function. Use this to handle actions for your row eg. use the id to make a DELETE call to your API.
Note:DropdownMenuLabel must sit inside a DropdownMenuGroup. See the dropdown menu docs for the full structure.
Pagination
Next, we'll add pagination to our table.
Register the pagination feature
app/payments/data-table-features.ts
import { createPaginatedRowModel, rowPaginationFeature, tableFeatures,} from "@tanstack/react-table";export const features = tableFeatures({ rowPaginationFeature, paginatedRowModel: createPaginatedRowModel(),});export type DataTableFeatures = typeof features;
This will automatically paginate your rows into pages of 10. See the pagination docs for more information on customizing page size and implementing manual pagination.
Add pagination controls
We can add pagination controls to our table using the <Button /> component and the table.previousPage(), table.nextPage() API methods.
Filtering is now enabled for the email column. You can add filters to other columns as well. See the filtering docs for more information on customizing filters.
Visibility
Adding column visibility is fairly simple using the @tanstack/react-table visibility API.
Register the visibility feature
Column visibility needs no row model of its own — just the feature.
Now that visibility is registered, swap the cell loop in <DataTable /> from row.getAllCells() to row.getVisibleCells() so hidden columns stop rendering.