• Home
BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Next.js
  4. shadcn Table vs Data Table: When to Choose TanStack

shadcn Table vs Data Table: When to Choose TanStack

Practical Next.js guide comparing shadcn/ui Table and a TanStack-powered Data Table — when to keep simple markup and…

25th February 2026·Updated on:22nd February 2026·MŽMatija Žiberna·
Next.js
shadcn Table vs Data Table: When to Choose TanStack

⚡ 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.

No spam. Unsubscribe anytime.

Related Posts:

  • •Complete Data Table with TanStack Table in Next.js
  • •Fix Dynamic Tailwind Classes with Class Registries
  • •Fix Navbar Shift with scrollbar-gutter: stable — One Line

I recently rebuilt a page in my Next.js app that lists cost objects — codes like P-000845 with a name, type, and status. The original version used shadcn's <Table> component directly. It worked fine until I needed sorting. Then filtering. Then pagination. At that point I wasn't writing table markup anymore — I was reinventing state management inside a component that was never designed for it.

That's when I switched to a Data Table powered by TanStack Table. But here's the thing: not every table in my app needed that upgrade. Some are still plain <Table> components and they're better for it.

This article breaks down both approaches using the same dataset so you can see exactly where one ends and the other begins.

The Static Table

shadcn/ui ships a <Table> component that wraps standard HTML table elements with consistent styling. It's a presentation component — you give it rows, it renders them. Nothing more.

Here's a cost objects table that displays a list fetched on the server:

// File: src/components/cost-objects/CostObjectsTable.tsx
"use client";

import Link from "next/link";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";

interface CostObject {
  id: number;
  code: string;
  name: string;
  typeName: string;
  status: string;
}

export function CostObjectsTable({ costObjects }: { costObjects: CostObject[] }) {
  if (costObjects.length === 0) {
    return (
      <div className="p-8 text-center text-muted-foreground border rounded-lg">
        No cost objects found.
      </div>
    );
  }

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Code</TableHead>
          <TableHead>Name</TableHead>
          <TableHead>Type</TableHead>
          <TableHead>Status</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {costObjects.map((co) => (
          <TableRow key={co.id}>
            <TableCell className="font-mono text-sm">
              <Link href={`/cost-objects/${co.code}`} className="hover:underline">
                {co.code}
              </Link>
            </TableCell>
            <TableCell>{co.name}</TableCell>
            <TableCell className="text-muted-foreground">{co.typeName}</TableCell>
            <TableCell>
              <Badge variant={co.status === "active" ? "default" : "secondary"}>
                {co.status === "active" ? "Active" : "Archived"}
              </Badge>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

This is straightforward. The component receives an array, maps over it, and renders rows. There is no internal state. Sorting order is whatever the server query returned. If you want to filter, you add an input and a .filter() call before the .map(). If you want pagination, you slice the array and track the current page yourself.

For a table with 10-20 rows that just needs to display data, this is the right tool. It's easy to read, easy to modify, and there's zero abstraction overhead.

Where the Static Table Breaks Down

The problems start when you need interactivity. Say a user wants to sort by name. You add a useState for the sort column and direction, a click handler on the header, and a .sort() before the .map(). That's manageable. Now they want to sort by code too. And type. You're duplicating the same toggle logic across multiple headers.

Then they want a search input that filters across name and code. Another useState, another handler, and now your sort and filter logic need to compose correctly — filter first, then sort, then slice for pagination. Each feature you bolt on adds state and makes the component harder to reason about.

This is the inflection point. Once your table needs two or more of sorting, filtering, or pagination, you're no longer writing a display component — you're building a table engine. And TanStack Table already is one.

The Data Table

TanStack Table is a headless UI library. It manages table state — sorting, filtering, pagination, selection — but renders nothing. You still use shadcn's <Table> for the markup. The difference is that a table instance coordinates everything for you.

The Data Table pattern splits into three files. This separation isn't arbitrary — it follows the server/client boundary in Next.js App Router.

The first file defines your columns. Each column maps to a data field and describes how the header and cell should render:

// File: src/components/cost-objects/columns.tsx
"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";

interface CostObject {
  id: number;
  code: string;
  name: string;
  typeName: string;
  status: string;
}

export const columns: ColumnDef<CostObject>[] = [
  {
    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/${row.original.code}`}
        className="font-mono text-sm hover:underline"
      >
        {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">{row.getValue("typeName")}</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>
      );
    },
  },
];

Notice the code and name columns have sortable headers. That's a one-line addition per column — column.toggleSorting() — rather than custom state wiring. The column definition is also where you control cell rendering: links, badges, formatted values. This keeps all column-specific logic in one place instead of scattered across JSX.

The second file is the Data Table component itself. It creates the table instance, wires up the features, and renders using shadcn's <Table>:

// File: src/components/cost-objects/CostObjectsDataTable.tsx
"use client";

import * as React from "react";
import {
  ColumnDef,
  SortingState,
  ColumnFiltersState,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  useReactTable,
} 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";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

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 },
  });

  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>
          <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>
      <div className="flex items-center justify-end gap-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

The key thing happening here is useReactTable. You pass it your data, columns, and the model functions you want — getSortedRowModel, getFilteredRowModel, getPaginationRowModel — and it returns a table instance that handles all the state coordination. Sorting, filtering, and pagination all compose automatically. You don't write the logic for how a filter interacts with pagination; the library handles that.

The flexRender function is how TanStack connects its headless logic to your actual JSX. It takes a column definition (which can be a string, a component, or a render function) and produces the rendered output. This is what makes the library headless — it computes what to render, flexRender bridges that into React elements.

The third file is the server page that fetches data and passes it down:

// File: src/app/(frontend)/cost-objects/page.tsx
import { columns } from "@/components/cost-objects/columns";
import { CostObjectsDataTable } from "@/components/cost-objects/CostObjectsDataTable";

export default async function CostObjectsPage() {
  const costObjects = await getCostObjects(); // your data fetching

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold tracking-tight">Cost Objects</h1>
      <CostObjectsDataTable columns={columns} data={costObjects} />
    </div>
  );
}

This three-file split matters because columns and the Data Table component must be client components (they use hooks and event handlers), while the page can remain a server component that fetches data without shipping that logic to the browser.

The Real Difference

Looking at both approaches side by side, the static Table and the Data Table render identical HTML. The same <table>, <thead>, <tbody>, <tr>, <td> elements. The visual output can be pixel-for-pixel the same.

The difference is where the logic lives. With a static Table, you own every piece of state — sort column, sort direction, filter value, current page. You wire them together manually and hope they compose correctly. With a Data Table, TanStack Table owns that state and exposes it through a consistent API. You declare which features you want by adding model functions, and the library coordinates them.

This is not a small distinction. When you add filtering to a sorted, paginated static table, you need to make sure filtering resets the page index to zero. With TanStack Table, that happens automatically. When you want to make a column sortable, you add one line to the column definition instead of threading state through your component.

When to Use Each

A static <Table> is the right choice when the data is display-only or close to it. A table that shows the five most recent orders on a dashboard. A settings page listing a handful of configuration values. A summary table inside a detail view. These tables have a fixed, small number of rows, and users don't need to search, sort, or page through them. The static Table keeps the code simple and the bundle small.

A Data Table earns its place when users need to interact with the data. The moment you need two or more of these — sorting, column filtering, global search, pagination, row selection — you should reach for TanStack Table. The upfront cost is higher: three files instead of one, a new dependency, and the useReactTable API to learn. But that cost is fixed. Each additional feature after the initial setup is a few lines of configuration, not a structural change to your component.

There's a grey zone when you only need one interactive feature, like sorting a single column. You can handle that with a useState and a .sort() in a static Table and it's fine. But if there's any chance the requirements will grow — and with data tables, they usually do — starting with TanStack Table saves you from a rewrite later.

Conclusion

The shadcn <Table> component and a TanStack-powered Data Table are not competing tools. They serve different complexity levels, and the shadcn <Table> is actually used inside the Data Table for rendering. The decision comes down to whether your table is a display component or an interactive one.

If you're rendering a short, static list — use <Table> directly. If users need to sort, filter, or paginate — build a Data Table. And if you're on the fence, ask yourself: will this table get more interactive over time? If yes, start with TanStack.

In the next article, I'll walk through building a production Data Table from scratch — including reusable patterns, custom toolbars, and the gotchas I hit along the way.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

📄View markdown version
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Complete Data Table with TanStack Table in Next.js
Complete Data Table with TanStack Table in Next.js

24th February 2026

Fix Dynamic Tailwind Classes with Class Registries
Fix Dynamic Tailwind Classes with Class Registries

20th January 2026

Fix Navbar Shift with scrollbar-gutter: stable — One Line
Fix Navbar Shift with scrollbar-gutter: stable — One Line

1st February 2026

Table of Contents

  • The Static Table
  • Where the Static Table Breaks Down
  • The Data Table
  • The Real Difference
  • When to Use Each
  • Conclusion
On this page:
  • The Static Table
  • Where the Static Table Breaks Down
  • The Data Table
  • The Real Difference
  • When to Use Each
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Payload CMS Websites
  • Bespoke AI Applications
  • Projects
  • How I Work
  • Blog
  • Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Contact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved