- Complete Data Table with TanStack Table in Next.js
Complete Data Table with TanStack Table in Next.js
Step-by-step implementation of a sortable, filterable, paginated data table using TanStack React Table and shadcn/ui…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Related Posts:
In the previous article, I compared shadcn's static <Table> with a TanStack-powered Data Table and laid out when each one makes sense. This article is the implementation side. We're going to build a Data Table from scratch, adding one feature at a time, so you understand what each piece does and can stop wherever your requirements end.
By the end, you'll have a fully working Data Table with sortable columns, a filter input, pagination, custom cell renderers, and a clean toolbar — all built on top of the same shadcn <Table> component you already know.
I'm using a cost objects dataset as the running example — rows with a code, name, type, description, and status — but the patterns apply to any data you're working with.
Prerequisites
You need two things installed. First, shadcn's Table component:
npx shadcn@latest add table
Second, TanStack React Table:
npm install @tanstack/react-table
If you're following along in a Next.js App Router project, you'll also want shadcn's <Button>, <Input>, and <Badge> components available. We'll use them for sorting controls, the filter input, and status cells.
The Three-File Architecture
Before writing any code, let's set up the file structure. A Data Table splits into three files, and this split isn't a stylistic choice — it follows the server/client boundary in Next.js App Router.
src/
├── app/(frontend)/cost-objects/
│ └── page.tsx ← Server component: fetches data
└── components/cost-objects/
├── types.ts ← Shared type definition
├── columns.tsx ← Client component: column definitions
└── CostObjectsDataTable.tsx ← Client component: table rendering
The page is a server component. It fetches data from your database or API and passes it as a prop. The columns file and the DataTable component are client components because they use React hooks and event handlers. This separation means your data-fetching logic never ships to the browser, while the interactive table logic runs entirely on the client.
Let's build each file, starting from the simplest working version and layering features on top.
Step 1: Define Your Data Shape
Start by defining the shape of the data your table will display. This type is shared between the server page (which produces the data) and the client components (which consume it).
// File: src/components/cost-objects/types.ts
export interface SerializedCostObject {
id: number;
code: string;
name: string;
typeName: string | null;
description: string | null;
status: string;
}
I prefix the type with "Serialized" deliberately. If you're using an ORM or CMS like Payload, your database objects contain methods, circular references, and relationship objects that can't be passed from a server component to a client component. You need a plain, serializable version. Defining this type explicitly makes that boundary visible in your code.
Step 2: Basic Column Definitions
The columns file is where you define what your table displays. Each entry in the columns array maps to one column in the table. At its simplest, a column just needs an accessorKey that matches a property on your data type, and a header string.
// File: src/components/cost-objects/columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { SerializedCostObject } from "./types";
export const columns: ColumnDef<SerializedCostObject>[] = [
{
accessorKey: "code",
header: "Code",
},
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "typeName",
header: "Type",
},
{
accessorKey: "description",
header: "Description",
},
{
accessorKey: "status",
header: "Status",
},
];
The "use client" directive is required because this file will be imported by the DataTable client component. The ColumnDef generic takes your data type so that accessorKey is type-checked — if you misspell a property name, TypeScript catches it.
This is the bare minimum. Every column renders its value as plain text. We'll add custom renderers, sorting, and formatting later, but the table will work with just this.
Step 3: The DataTable Component
This is the core file. It creates a TanStack table instance with useReactTable and renders it using shadcn's <Table> components. Here's the minimal version — no sorting, no filtering, no pagination. Just data in, table out.
// File: src/components/cost-objects/CostObjectsDataTable.tsx
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function CostObjectsDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
Let's unpack what's happening here. The useReactTable hook takes your data, columns, and a getCoreRowModel function. That last argument is required — it's the base model that computes which rows to display. Every other feature (sorting, filtering, pagination) layers on top of it as additional model functions.
The flexRender function is the bridge between TanStack's headless logic and your React JSX. A column's header or cell can be a string, a React component, or a render function. flexRender handles all three cases and returns the appropriate React element. You'll see it used twice — once for headers, once for cells — and you'll never need to think about it beyond that.
The generic <TData, TValue> on the component means this same component could technically render any data type. For now it's specific to cost objects by name, but the interface already accepts any columns and data pair.
Step 4: The Server Page
The server page fetches data and passes it to the client components. This is where the serialization boundary lives.
// File: src/app/(frontend)/cost-objects/page.tsx
import { columns } from "@/components/cost-objects/columns";
import { CostObjectsDataTable } from "@/components/cost-objects/CostObjectsDataTable";
import type { SerializedCostObject } from "@/components/cost-objects/types";
export default async function CostObjectsPage() {
// Replace this with your actual data fetching
const rawData = await fetchCostObjects();
// Serialize into plain objects for the client
const costObjects: SerializedCostObject[] = rawData.map((co) => ({
id: co.id,
code: co.code,
name: co.name,
typeName: typeof co.type === "object" ? co.type.name : null,
description: co.description ?? null,
status: co.status,
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Cost Objects</h1>
<p className="text-muted-foreground mt-1">
Browse and filter all cost objects
</p>
</div>
<CostObjectsDataTable columns={columns} data={costObjects} />
</div>
);
}
The .map() that produces SerializedCostObject[] is critical. If your data source returns rich objects with nested relationships (like co.type being a full object with its own id, name, and methods), you need to flatten them into plain values. React Server Components can only pass serializable data to client components. Passing a Payload CMS document directly, for example, would fail silently or throw a serialization error.
At this point you have a working table. It displays all your data in a clean bordered table with headers. No interactivity yet — but the foundation is solid. Every feature from here is additive.
Step 5: Add Sorting
Sorting requires two changes: add a state and model function to the DataTable, and make column headers clickable in the columns file.
First, update the DataTable component:
// File: src/components/cost-objects/CostObjectsDataTable.tsx
"use client";
import * as React from "react";
import {
ColumnDef,
SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
// ... Table imports remain the same
export function CostObjectsDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
state: { sorting },
});
// ... return JSX remains identical
}
Three additions: a SortingState that tracks which column is sorted and in which direction, the getSortedRowModel function that tells TanStack to compute sorted rows, and the onSortingChange callback that keeps your state in sync. The render code doesn't change at all — TanStack's table.getRowModel().rows now returns sorted rows automatically.
Now make the column headers interactive. Update the columns you want to be sortable:
// File: src/components/cost-objects/columns.tsx (updated)
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { SerializedCostObject } from "./types";
export const columns: ColumnDef<SerializedCostObject>[] = [
{
accessorKey: "code",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Code
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "typeName",
header: "Type",
},
// ... other columns unchanged
];
The header property accepts either a string or a function. When it's a function, it receives the column instance which has methods like toggleSorting and getIsSorted. The ArrowUpDown icon from Lucide gives users a visual cue that the column is sortable. Columns that don't need sorting — like "Type" — keep the plain string header.
The pattern here is worth noting: you don't need to make every column sortable. Only add the interactive header to columns where sorting is useful. TanStack doesn't force uniformity.
Step 6: Add Filtering
Filtering follows the same pattern as sorting — add state, add a model function, add UI. Update the DataTable:
// File: src/components/cost-objects/CostObjectsDataTable.tsx (updated)
import {
ColumnDef,
SortingState,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Input } from "@/components/ui/input";
export function CostObjectsDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: { sorting, columnFilters },
});
return (
<div className="space-y-4">
<Input
placeholder="Filter by name..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="max-w-sm"
/>
<div className="overflow-hidden rounded-md border">
<Table>
{/* ... same table rendering as before */}
</Table>
</div>
</div>
);
}
The getFilteredRowModel tells TanStack to compute filtered rows. The ColumnFiltersState is an array of { id, value } pairs — one entry per filtered column. When the user types in the input, setFilterValue on the "name" column updates that state, and TanStack recomputes the visible rows.
The filter input targets a specific column by name via table.getColumn("name"). You can add multiple filter inputs for different columns using the same pattern. Each column filters independently, and TanStack applies them all before passing rows to the sort and pagination models.
One thing to watch: the string you pass to table.getColumn() must match the accessorKey in your column definition. If your column uses accessorKey: "typeName", the filter must target "typeName", not "type" or "Type".
Step 7: Add Pagination
Pagination is the simplest feature to add because it requires no changes to the column definitions.
// File: src/components/cost-objects/CostObjectsDataTable.tsx (updated)
import {
// ... existing imports
getPaginationRowModel,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
export function CostObjectsDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: { sorting, columnFilters },
initialState: {
pagination: { pageSize: 25 },
},
});
return (
<div className="space-y-4">
<Input
placeholder="Filter by name..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="max-w-sm"
/>
<div className="overflow-hidden rounded-md border">
<Table>
{/* ... same table rendering */}
</Table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} result(s) total
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}
Adding getPaginationRowModel() is enough to enable pagination. TanStack defaults to 10 rows per page. I override that with initialState: { pagination: { pageSize: 25 } } because 10 feels too few for a data-heavy table.
The pagination API is straightforward: table.previousPage() and table.nextPage() navigate, table.getCanPreviousPage() and table.getCanNextPage() tell you when to disable buttons, and table.getPageCount() gives you the total.
One important behavior: when the user applies a filter that reduces the row count below the current page, TanStack automatically resets to page one. You don't need to handle that edge case manually. This is exactly the kind of state coordination that makes the library worth using.
Step 8: Custom Cell Renderers
So far, every cell renders its value as plain text. Real tables need links, badges, formatted dates, and truncated descriptions. You control this through the cell property on each column definition.
Here's the complete columns file with custom renderers:
// File: src/components/cost-objects/columns.tsx (final)
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { SerializedCostObject } from "./types";
export const columns: ColumnDef<SerializedCostObject>[] = [
{
accessorKey: "code",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Code
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<Link
href={`/cost-objects/${encodeURIComponent(row.original.code)}`}
className="font-mono text-sm font-medium hover:underline underline-offset-2"
>
{row.original.code}
</Link>
),
},
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "typeName",
header: "Type",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.getValue("typeName") || "\u2014"}
</span>
),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm max-w-[300px] truncate block">
{row.getValue("description") || "\u2014"}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge variant={status === "active" ? "default" : "secondary"}>
{status === "active" ? "Active" : "Archived"}
</Badge>
);
},
},
];
There are two ways to access data inside a cell function. row.getValue("columnId") returns the value for a specific column through TanStack's accessor system. row.original returns the entire original data object. Use row.original when you need multiple fields — like building a link URL from code while displaying code as text. Use row.getValue() when you only need the column's own value.
The em dash (\u2014) for null values is a small detail that makes a difference. An empty cell looks like a rendering bug. A dash communicates "no value" intentionally.
For the description column, max-w-[300px] truncate block prevents long text from blowing out the table layout. The block class is necessary because truncate requires a block-level element to work — by default, a <span> is inline and won't clip.
Step 9: Extract a Toolbar
As your table grows, the filter input and any other controls above the table become a toolbar. Extracting it into its own component keeps the DataTable focused on rendering.
// File: src/components/cost-objects/CostObjectsDataTableToolbar.tsx
"use client";
import { Table } from "@tanstack/react-table";
import { Input } from "@/components/ui/input";
interface ToolbarProps<TData> {
table: Table<TData>;
}
export function CostObjectsDataTableToolbar<TData>({
table,
}: ToolbarProps<TData>) {
return (
<div className="flex items-center gap-2">
<Input
placeholder="Filter by name..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="max-w-sm"
/>
<Input
placeholder="Filter by code..."
value={(table.getColumn("code")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("code")?.setFilterValue(e.target.value)
}
className="max-w-xs"
/>
</div>
);
}
The toolbar receives the table instance and uses the same getColumn().setFilterValue() API. You can add as many filter inputs, dropdowns, or action buttons as needed without touching the DataTable component.
Update the DataTable to use the toolbar:
// File: src/components/cost-objects/CostObjectsDataTable.tsx (with toolbar)
import { CostObjectsDataTableToolbar } from "./CostObjectsDataTableToolbar";
export function CostObjectsDataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
// ... useReactTable setup unchanged
return (
<div className="space-y-4">
<CostObjectsDataTableToolbar table={table} />
<div className="overflow-hidden rounded-md border">
<Table>
{/* ... table rendering */}
</Table>
</div>
{/* ... pagination controls */}
</div>
);
}
This is cleaner. The DataTable handles structure and rendering. The toolbar handles user controls. Each has a single responsibility.
Gotchas Worth Knowing
There are a few things that tripped me up during implementation that aren't obvious from the documentation.
The accessorKey must match your data property exactly. If your serialized type has typeName but you write accessorKey: "type_name", the column will render empty with no error. TypeScript catches this if you type your ColumnDef correctly, but it's easy to miss when renaming fields.
The "use client" directive is required on both columns.tsx and the DataTable component. If you forget it on the columns file and it only exports a plain array, you might not get an error immediately — but the moment a column uses JSX in its header or cell, the build will fail with a confusing message about server components not supporting functions.
When you pass data from a server component to the DataTable, every value must be serializable. Dates should be ISO strings, not Date objects. Nested relationships should be flattened. If you pass a Date object, it might work in development but fail in production builds.
The order of model functions matters conceptually but not mechanically. TanStack applies them in a fixed pipeline: core rows, then filtered, then sorted, then paginated. The order you list them in the useReactTable config doesn't change the behavior, but listing them in pipeline order makes the code easier to read.
Filter values are always per-column by default. TanStack doesn't include a built-in global search across all columns. If you need global filtering, you can add a custom filterFn on individual columns or use the globalFilter state, which is a separate API from columnFilters.
Conclusion
Building a Data Table with TanStack Table follows a consistent pattern: define your columns, create a table instance with the features you need, and render with shadcn's <Table>. Each feature — sorting, filtering, pagination — is a state variable plus a model function. The render code barely changes as you add capabilities.
The three-file architecture keeps concerns separated across the server/client boundary: the page fetches and serializes data, the columns file defines structure and appearance, and the DataTable component handles rendering and state. Once this skeleton is in place, adding a new column or a new feature is a localized change rather than a refactor.
What makes this approach scale is that TanStack coordinates the interaction between features for you. Filtering resets pagination. Sorting preserves the current filter. You declare what you want, and the library handles how they compose.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija


