TanStack Table v8 + shadcn/ui Table is the recommended pattern for data tables in modern React apps. This page covers 3 patterns — basic table, sortable/filterable and paginated.
Installation
npm install @tanstack/react-table
npx shadcn@latest add table button input dropdown-menu
Core Concepts
TanStack Table is headless — it gives you state and behavior, you provide the UI:
const table = useReactTable({
data, // your data array
columns, // column definitions
getCoreRowModel: getCoreRowModel(), // required
getSortedRowModel: getSortedRowModel(), // for sorting
getFilteredRowModel: getFilteredRowModel(), // for search
getPaginationRowModel: getPaginationRowModel(), // for pagination
})
Then render with shadcn Table components and flexRender.
1. Basic shadcn/ui Table
Static data table using shadcn/ui Table components — no library needed for simple tables.
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
const templates = [
{ id: 1, name: "Marvel Angular Dashboard", framework: "Angular 21", price: "$29", sales: 1240, status: "Active" },
{ id: 2, name: "PORTO Bootstrap Template", framework: "Bootstrap 5", price: "$19", sales: 3580, status: "Active" },
{ id: 3, name: "Proctu Medical React", framework: "React 19", price: "$39", sales: 890, status: "Draft" },
{ id: 4, name: "Kiosk Next.js Dashboard", framework: "Next.js 15", price: "$29", sales: 620, status: "Active" },
{ id: 5, name: "CANVA Bootstrap UI Kit", framework: "Bootstrap 5", price: "$15", sales: 2100, status: "Active" },
]
const statusColor: Record<string, string> = {
Active: "bg-green-100 text-green-700",
Draft: "bg-yellow-100 text-yellow-700",
Archived: "bg-gray-100 text-gray-600",
}
export default function BasicTable() {
return (
<div className="rounded-xl border bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Template</TableHead>
<TableHead className="font-semibold">Framework</TableHead>
<TableHead className="font-semibold">Price</TableHead>
<TableHead className="font-semibold">Sales</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-50">
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">{row.framework}</TableCell>
<TableCell className="font-semibold" style={{ color: "#fd4766" }}>{row.price}</TableCell>
<TableCell>{row.sales.toLocaleString()}</TableCell>
<TableCell>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColor[row.status]}`}>
{row.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm">Edit</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">Delete</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}2. Sortable & Filterable Table with TanStack
Full-featured data table using TanStack Table v8 with column sorting and search filter.
import { useState, useMemo } from "react"
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table"
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react"
type Template = {
name: string
framework: string
price: number
sales: number
status: string
}
const data: Template[] = [
{ name: "Marvel Angular Dashboard", framework: "Angular", price: 29, sales: 1240, status: "Active" },
{ name: "PORTO Bootstrap Template", framework: "Bootstrap", price: 19, sales: 3580, status: "Active" },
{ name: "Proctu Medical React", framework: "React", price: 39, sales: 890, status: "Draft" },
{ name: "Kiosk Next.js Dashboard", framework: "Next.js", price: 29, sales: 620, status: "Active" },
{ name: "CANVA UI Kit", framework: "Bootstrap", price: 15, sales: 2100, status: "Active" },
]
const SortIcon = ({ sorted }: { sorted: false | "asc" | "desc" }) => {
if (!sorted) return <ArrowUpDown size={14} className="text-muted-foreground opacity-50" />
return sorted === "asc"
? <ArrowUp size={14} style={{ color: "#fd4766" }} />
: <ArrowDown size={14} style={{ color: "#fd4766" }} />
}
export default function SortableTable() {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState("")
const columns = useMemo<ColumnDef<Template>[]>(() => [
{
accessorKey: "name",
header: ({ column }) => (
<button className="flex items-center gap-1.5 font-semibold hover:text-foreground"
onClick={() => column.toggleSorting()}>
Template <SortIcon sorted={column.getIsSorted()} />
</button>
),
},
{
accessorKey: "framework",
header: ({ column }) => (
<button className="flex items-center gap-1.5 font-semibold hover:text-foreground"
onClick={() => column.toggleSorting()}>
Framework <SortIcon sorted={column.getIsSorted()} />
</button>
),
},
{
accessorKey: "price",
header: ({ column }) => (
<button className="flex items-center gap-1.5 font-semibold hover:text-foreground"
onClick={() => column.toggleSorting()}>
Price <SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => (
<span className="font-semibold" style={{ color: "#fd4766" }}>
${row.original.price}
</span>
),
},
{
accessorKey: "sales",
header: ({ column }) => (
<button className="flex items-center gap-1.5 font-semibold hover:text-foreground"
onClick={() => column.toggleSorting()}>
Sales <SortIcon sorted={column.getIsSorted()} />
</button>
),
cell: ({ row }) => row.original.sales.toLocaleString(),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
row.original.status === "Active"
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}>
{row.original.status}
</span>
),
},
], [])
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search templates..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="pl-9"
/>
</div>
<span className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} results
</span>
</div>
<div className="rounded-xl border bg-white overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="bg-gray-50 hover:bg-gray-50">
{hg.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
No results found
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)
}3. Data Table with Pagination
TanStack Table with client-side pagination and rows-per-page selector.
import { useState, useMemo } from "react"
import {
useReactTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"
// Generate more rows for pagination demo
const generateData = () => Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
name: ["Marvel Dashboard", "PORTO Template", "Proctu Medical", "Kiosk Dashboard", "CANVA Kit"][i % 5],
framework: ["Angular", "Bootstrap", "React", "Next.js", "Bootstrap"][i % 5],
price: [29, 19, 39, 29, 15][i % 5],
sales: Math.floor(Math.random() * 2000) + 200,
}))
export default function PaginatedTable() {
const data = useMemo(() => generateData(), [])
const [globalFilter, setGlobalFilter] = useState("")
const columns = useMemo<ColumnDef<typeof data[0]>[]>(() => [
{ accessorKey: "id", header: "#", size: 50 },
{ accessorKey: "name", header: "Template" },
{ accessorKey: "framework", header: "Framework" },
{
accessorKey: "price",
header: "Price",
cell: ({ row }) => <span style={{ color: "#fd4766" }}>${row.original.price}</span>
},
{
accessorKey: "sales",
header: "Sales",
cell: ({ row }) => row.original.sales.toLocaleString()
},
], [])
const table = useReactTable({
data,
columns,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
initialState: { pagination: { pageSize: 5 } },
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<Input placeholder="Search..." value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page:</span>
<select
className="border rounded px-2 py-1 text-sm"
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(+e.target.value)}
>
{[5, 10, 25].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
<div className="rounded-xl border bg-white overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id} className="bg-gray-50 hover:bg-gray-50">
{hg.headers.map(h => (
<TableHead key={h.id} style={{ width: h.column.getSize() }}>
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<TableRow key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination controls */}
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} ·{" "}
{table.getFilteredRowModel().rows.length} total
</p>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-8 w-8"
onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>
<ChevronsLeft size={14} />
</Button>
<Button variant="outline" size="icon" className="h-8 w-8"
onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
<ChevronLeft size={14} />
</Button>
<Button variant="outline" size="icon" className="h-8 w-8"
onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
<ChevronRight size={14} />
</Button>
<Button variant="outline" size="icon" className="h-8 w-8"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}>
<ChevronsRight size={14} />
</Button>
</div>
</div>
</div>
)
}Frequently Asked Questions
Need a Full React + Next.js Dashboard Template?
Get a complete React + Next.js dashboard with 50+ components — built by the same team behind BootstrapPlanet.
Browse Templates →Use code FIRST30 for 30% off.