# shadcn ships no data table component, so build sorting and paging yourself

> shadcn ships a guide, not a data table. Add sorting, filtering and pagination with TanStack Table, with code for each step.
> By Dave · 2026-10-13
> Source: https://otf-kit.dev/blog/shadcn-data-table-howto

The shadcn/ui docs have no data table component to install. They offer a guide instead: "instead of a data-table component, I thought it would be more helpful to provide a guide on how to build your own." The guide leaves sorting, filtering and pagination for you to wire up. This article walks through that pattern, shows where each feature lives in the code, and ends with where a ready-made starting point fits.

## What the shadcn guide gives you

The guide is a set of pieces, not a finished table. It includes a reusable column header and a pagination control, then wires them to a table built with TanStack Table. You install the library with `pnpm add @tanstack/react-table`. The shadcn page does the heavy lifting in the example, but the logic that sorts, filters and pages rows lives in TanStack Table.

Two defaults matter before you write any code. Pagination is on by default, and the guide's table automatically splits rows into pages of 10. Sorting and filtering are off until you enable them, so you wire each one on purpose.

## Set up the table state

TanStack Table is headless. It calculates which rows to show, and your components decide how to render them. Sorting and filtering need state that you own, so you keep that state in React and hand it to the table:

```tsx
import * as React from "react"
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
  type ColumnDef,
  type ColumnFiltersState,
  type SortingState,
} from "@tanstack/react-table"

export function UsersTable<TData, TValue>({
  columns,
  data,
}: {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])

  const table = useReactTable({
    data,
    columns,
    state: { sorting, columnFilters },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  })

  return null // render the header, body and footer from `table` here
}
```

Each row model is opt-in. The sorted, filtered and pagination models each process the rows that the one before it returns, so the order matters. Core comes first, then sorting, filtering and paging.

## Add sorting

The guide enables sorting through the table's features and the sorted row model, then wires the header to the sort state. Clicking a header toggles ascending and descending order. The guide uses an email column as its example. In your header cell, call `toggleSorting` with the direction you want next:

```tsx
<Button
  variant="ghost"
  onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
  Email
  <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
```

`column.getIsSorted()` returns `"asc"`, `"desc"` or `false`. If the column is already ascending, the next click sorts it descending. Otherwise it sorts ascending. Keep the header a real button, not a styled div, so keyboard users can reach it and press Enter to sort.

Single-column sorting covers most dashboards. If your users need two fields at once, such as status and then date, turn on multi-sort and keep the header's click handler in one shared component. That keeps the behavior identical across columns. Changing the sort model after the headers are built means touching every column definition, so decide early.

![Luna points a stylus at a sort arrow on a holographic table header, with stacked rows below](https://cdn.otf-kit.dev/blog/shadcn-data-table-howto/inbody1-20261013-v2.png)

## Add filtering

Filtering works column by column. The guide's example adds a search input that filters the email column as you type. Read the current filter value so the input stays controlled, and write a new value on each change:

```tsx
<Input
  placeholder="Filter emails..."
  value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
  onChange={(event) =>
    table.getColumn("email")?.setFilterValue(event.target.value)
  }
  className="max-w-sm"
/>
```

Filtering reduces the rows before pagination runs. That order is why you should set the filter first and let the paging controls follow it. If the filter drops the table to one page, the Next button should disable itself, and the page count should update without a manual reset.

## Add pagination

With `getPaginationRowModel` in place, the table pages rows automatically. The default is 10 rows per page. The footer below uses two buttons, Previous and Next, and disables each one when the table cannot move in that direction:

```tsx
<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>
```

If 10 rows per page does not suit your data, call `table.setPageSize(20)` from a select control. Keep the page size visible on the screen so users know how much they are looking at. Hiding it makes a short list look like the whole data set.

This pattern is client-side: every row is loaded into the browser, and the table sorts, filters and pages it there. That suits tables where the full data set is small enough to send in one request. When the data is too large to load at once, paging has to move to the server, and the row models above do less of the work. Pick the approach before you build the controls, because the two need different state.

![Dex and Luna at holographic panels: a filter input on the left and pagination arrows on the right](https://cdn.otf-kit.dev/blog/shadcn-data-table-howto/inbody2-20261013-v2.png)

## Test the three features together

Sorting, filtering and pagination interact in ways a single-feature test will miss. Check these cases before you ship:

- Sort a column, then filter it. The rows should stay sorted within the filtered set.
- Filter to fewer rows than one page. Next should disable, and Previous should stay disabled on page one.
- Change the filter while you are on page three. The table should move back to a valid page instead of showing an empty grid.
- Tab to each sort button and press Enter. The sort should change without a mouse.

Run these with real row counts. Fifty rows and five thousand rows behave differently once the filter and paging are both active, and the second case is where slow renders show up first.

## Handle empty and loading states

A filtered table needs an empty state that says why it is empty. When the filter removes every row, write "No users match this filter" and give the user a way out, such as a clear button that calls `table.resetColumnFilters()`. Without that message, the user sees a blank body and assumes the data is missing.

Loading needs the same care. Render the header and footer while data loads, so the layout does not jump when rows arrive. Pass an empty array while the request is in flight and show a skeleton row in the body. Users then see the columns they will get, and the first screen looks finished before the data arrives.

Keep column definitions stable as well. Define them outside the component or memoize them with `useMemo`. When the columns array changes identity on every render, the table recalculates on every render too. That is the most common reason a table feels slow after a small change. Typing in the filter box should only change filter state, not rebuild the columns.

## Where a kit fits

If you are building a product dashboard rather than a single table, the decision is whether to write this layer yourself or start from a kit that already has one. The OTF SaaS Dashboard Kit is a $99 one-time purchase, and its demo is free. Its kit page lists data tables in the working dashboard, next to projects, teams and analytics.

The kit page does not spell out sorting, filtering or pagination for those tables. Before you buy, check the source repo and the setup guide for the exact behavior you need. For a screen-by-screen review of the kit, read [the SaaS dashboard checklist for project operations](/blog/saas-dashboard-for-project-ops) first.

A fuller data grid is not an option yet. The Data Grid that the OTF pricing page lists as a Pro piece is marked "Not for sale yet," so do not plan a launch around it.

## Sources

- shadcn/ui, Data table guide: https://ui.shadcn.com/docs/components/data-table
- OTF SaaS Dashboard Kit: [otf-kit.dev/templates/saas-dashboard](/templates/saas-dashboard)
