# TanStack Table v9 Data Table: Filters, Bulk Actions, Mobile

> Build an admin data table on TanStack Table v9: tableFeatures setup, column filters, bulk actions, start and end pinning, server pagination and phone cards.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/tanstack-table-v9-data-table

## TL;DR

A TanStack Table v9 data table starts with tableFeatures: you register sorting, filtering, pagination, pinning and selection once at module scope and pass them to useTable, which replaces useReactTable. For server pagination you set manualPagination, manualSorting and manualFiltering with a rowCount from your API, and column pinning now uses start and end instead of left and right. The library gives you state and row models only; the filter UI, the bulk bar and a phone layout are still yours to build or install.

TanStack Table v9 went stable in August 2026, and many data table tutorials still show the v8 API: `useReactTable`, `getCoreRowModel()` and `columnPinning.left`. Copy one into a v9 project and the import fails, because `useReactTable` is gone; switch to `useTable` and nothing sorts until you register the sorting feature. This tutorial builds an admin data table on TanStack Table v9: column filters, bulk actions on selected rows, pinned columns and server pagination, plus the card layout it should switch to on a phone. The first half is plain TanStack, so it works with any markup. The second half uses the Wingo UI [Data Table](https://wingo-ui.com/components/data-table), which we build, so weigh our opinions accordingly.

## What changed in TanStack Table v9 for a data table?

Features became explicit. You declare sorting, filtering, pagination, pinning and selection in a `tableFeatures()` object, and the row model factories live in that object instead of in the table options. The renames that touch a data table, from the [React migration guide](https://tanstack.com/table/latest/docs/framework/react/guide/migrating) as of October 2026:

| v8 | v9 |
| --- | --- |
| `useReactTable(options)` | `useTable(options, selector?)` with a required `features` option |
| `getCoreRowModel: getCoreRowModel()` | automatic |
| `getSortedRowModel: getSortedRowModel()` | `sortedRowModel: createSortedRowModel()` inside `tableFeatures` |
| `columnPinning: { left, right }`, `column.pin("left")` | `columnPinning: { start, end }`, `column.pin("start")` |
| `sortingFn`, `sortingFns` | `sortFn`, `sortFns` |
| `table.getState()` | `table.state`, or `table.Subscribe` for one slice |
| `createColumnHelper<Person>()` | `createColumnHelper<typeof features, Person>()` |

Three behavior changes bite during a migration. In 9.2.4, `row.getVisibleCells()` comes from `columnVisibilityFeature`, so a table without it has only `getAllCells()`, which ignores pinning order. `getIsSomeRowsSelected()` returns true when every row is selected, so an indeterminate header checkbox needs `getIsSomeRowsSelected() && !getIsAllRowsSelected()`. And `row.getToggleSelectedHandler()` selects a range on Shift-click by default (`enableRowRangeSelection: false` turns it off).

The [v9 announcement](https://tanstack.com/blog/announcing-tanstack-table-v9) (August 4, 2026) says the whole package grew from 14kb to 25kb, and that features you do not register stay out of the bundle. Register what the table uses and nothing else.

## How do you set up TanStack Table v9 with filters, selection and pinning?

Build the features object and the columns once at module scope, then call `useTable` with the data. TanStack's docs ask for stable `features`, `columns` and `data`, and a fresh `data` array on every render invalidates every row model built from it. Here is a complete TanStack Table v9 example for a clients list:

```tsx
// clients-grid.tsx
"use client";

import type { CSSProperties } from "react";
import {
  columnFilteringFeature, columnPinningFeature, columnSizingFeature, columnVisibilityFeature,
  createColumnHelper, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel,
  filterFn_arrIncludesSome, filterFn_includesString, rowPaginationFeature, rowSelectionFeature,
  rowSortingFeature, sortFn_alphanumeric, sortFn_basic, tableFeatures, useTable, type Column,
} from "@tanstack/react-table";

export type Client = { id: string; name: string; city: string; status: string; balance: number };

const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
  columnFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
  filterFns: { includesString: filterFn_includesString, arrIncludesSome: filterFn_arrIncludesSome },
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
  columnSizingFeature,
  columnPinningFeature,
  columnVisibilityFeature,
  rowSelectionFeature,
});

const helper = createColumnHelper<typeof features, Client>();
const columns = helper.columns([
  helper.display({
    id: "select",
    size: 40,
    header: ({ table }) => (
      <input
        type="checkbox"
        aria-label="Select page"
        checked={table.getIsAllPageRowsSelected()}
        // v9: "some" stays true when all are selected
        ref={(el) => {
          if (el) el.indeterminate = table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected();
        }}
        onChange={table.getToggleAllPageRowsSelectedHandler()}
      />
    ),
    cell: ({ row }) => (
      <input type="checkbox" aria-label="Select row" checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} />
    ),
  }),
  helper.accessor("name", { header: "Client", size: 220, filterFn: "includesString", sortFn: "alphanumeric" }),
  helper.accessor("city", { header: "City", filterFn: "arrIncludesSome" }),
  helper.accessor("status", { header: "Status", filterFn: "arrIncludesSome" }),
  helper.accessor("balance", { header: "Balance", sortFn: "basic" }),
]);

// fixed widths keep the sticky offsets true; pinned cells need a background
function cellStyle(column: Column<typeof features, Client, unknown>): CSSProperties {
  const width = column.getSize();
  const side = column.getIsPinned();
  if (side === "start") return { width, position: "sticky", insetInlineStart: column.getStart("start"), zIndex: 1, background: "Canvas" };
  if (side === "end") return { width, position: "sticky", insetInlineEnd: column.getAfter("end"), zIndex: 1, background: "Canvas" };
  return { width };
}

export function ClientsGrid({ data }: { data: Client[] }) {
  const table = useTable({
    features,
    columns,
    data,
    getRowId: (row) => row.id,
    initialState: { columnPinning: { start: ["select", "name"], end: [] } },
  });

  return (
    <div style={{ overflowX: "auto" }}>
      <table style={{ tableLayout: "fixed", width: table.getTotalSize() }}>
        <thead>
          {table.getHeaderGroups().map((group) => (
            <tr key={group.id}>
              {group.headers.map((header) => (
                <th key={header.id} style={cellStyle(header.column)}>
                  {header.column.getCanSort() ? (
                    <button type="button" onClick={header.column.getToggleSortingHandler()}>
                      <table.FlexRender header={header} />
                    </button>
                  ) : (
                    <table.FlexRender header={header} />
                  )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} style={cellStyle(cell.column)}>
                  <table.FlexRender cell={cell} />
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

The fixed layout and the widths matter. `getStart("start")` returns 40 for the name column, the select column's `size`, so the browser has to draw that column 40px wide, or the pinned name column sticks with a gap in front of it. `Canvas` is the page background in the active color scheme; swap in your own token.

The filter functions are registered by name, so `filterFn: "arrIncludesSome"` is type-checked against the registry. Filtering and bulk work go through the same instance:

```ts
table.getColumn("status")?.setFilterValue(["Overdue"]);
const ids = table.getSelectedRowModel().rows.map((row) => row.original.id);
```

That is the whole job of the library: state, row models and handlers. Everything a person sees is still missing: a search box, filter menus with checkboxes and ranges, chips for the applied filters, a bar that swaps in when rows are selected, keyboard navigation between rows and a layout for 390px screens. If you started from the shadcn data table guide, [it is written for TanStack Table v9](https://ui.shadcn.com/docs/components/data-table) as of October 2026 and adds sorting, a text filter, column visibility, client pagination, a row actions menu and a selected-row count. Server pagination, pinning, bulk actions and a phone layout are not in it.

## How do you get filters, bulk actions and pinning without building the UI?

Install a component that builds the same kind of TanStack table and renders those parts. Our [Data Table](https://wingo-ui.com/components/data-table) runs on TanStack Table v9 with its features registered once at module scope, and takes plain column objects:

```bash
npx wingo-ui@latest login   # once, with a Pro account
npx wingo-ui@latest add data-table
```

```tsx
// clients-table.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Archive, Download, Pencil, Plus, Trash2 } from "lucide-react";
import { DataTable, createColumns } from "@/components/ui/data-table";
// your server actions; each takes the selected ids
import { archiveClients, deleteClients, exportClients } from "./actions";

export type Client = {
  id: string;
  name: string;
  taxId: string;
  city: string;
  status: "Active" | "Overdue" | "Prospect";
  owner: string;
  balance: number;
  lastInvoice: string | null;
};

const col = createColumns<Client>();
export const columns = col([
  col.accessor("name", { header: "Client", filter: "text", pinned: "left", mobile: "title" }),
  col.accessor("city", { header: "City", filter: "multi-select", mobile: "subtitle" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } },
    filter: "multi-select",
    mobile: "meta",
  }),
  col.accessor("owner", { header: "Owner", type: "avatar", filter: "select" }),
  col.accessor("balance", { header: "Balance", type: "currency", footer: "sum", filter: "number-range" }),
  col.accessor("lastInvoice", { header: "Last invoice", type: "relative", filter: "date-range" }),
  // hidden by default, still found by the search
  col.accessor("taxId", { header: "Tax ID", searchable: true, mobile: "hidden" }),
]);

export function ClientsTable({ clients }: { clients: Client[] }) {
  const router = useRouter();
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <DataTable
      data={clients}
      columns={columns}
      caption="Clients"
      locale="en-US"
      searchPlaceholder="Search by name, tax ID or city"
      defaultColumnVisibility={{ taxId: false }}
      quickFilters={[
        { id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } },
        { id: "big", label: "Over $10k", filter: (row) => row.balance > 10_000 },
      ]}
      selectionMode="multiple"
      selectedKeys={selected}
      onSelectedKeysChange={setSelected}
      bulkActions={(_rows, keys) => [
        { label: "Export", icon: <Download />, onSelect: () => exportClients(keys) },
        { label: "Archive", icon: <Archive />, onSelect: () => archiveClients(keys).then(() => setSelected([])) },
        { label: "Delete", icon: <Trash2 />, tone: "danger", onSelect: () => deleteClients(keys).then(() => setSelected([])) },
      ]}
      rowActions={(row) => [{ label: "Edit", icon: <Pencil />, href: `/clients/${row.id}` }]}
      toolbarActions={[{ label: "New client", icon: <Plus />, primary: true, onSelect: () => router.push("/clients/new") }]}
      persistKey="clients"
    />
  );
}
```

`createColumns<Client>()` checks every accessor key against the row type, and `pinned: "left"` becomes `columnPinning.start` in the TanStack state. Each header's menu can also pin, move or hide a column at runtime, and `persistKey` remembers visibility, order, widths, density and page size in local storage. The `rowActions` menu gets a ⋯ column pinned to the end, the same menu on right-click, and Shift+F10 on a focused row.

> Live demo (Data Table): Select a few rows and the toolbar turns into the bulk bar; open a header's chevron to filter or pin that column, or press the Overdue chip. Below 768px each row is a card: long press one to start selecting. Try it at [Data Table](https://wingo-ui.com/components/data-table) and install it with `npx wingo-ui@latest add data-table`.

### Which filter fits which column?

Pick the filter by the question people ask of the column, and know the value shape, because a server has to parse it. These React data table filters, with the values `onColumnFiltersChange` and the server query carry:

| `filter` | Use it for | Value |
| --- | --- | --- |
| `text` | names, emails, ids | `"cafe"` |
| `select` | one owner, one plan | `"Emma Carter"` |
| `multi-select` | statuses, cities, tags | `["Active", "Overdue"]` |
| `number-range` | balances, quantities | `[1000, null]` (either end may be null) |
| `date-range` | invoice dates, sign-ups | `{ from, to }`, each a `Date` or null (ISO strings once JSON-encoded) |

In client mode the select filters list the column's unique values with counts, and the search box matches every word across the searchable columns, accents and formatted values included: "cafe" finds Café and "12480" finds $12,480.00. Quick filters are chips for the two or three questions someone asks every morning. A chip with a `columnFilter` works in both modes; a chip with a `filter` function runs in the browser only.

## How should bulk actions work across pages?

Act on the ids, not on the row objects. `bulkActions` receives `(rows, keys)`: `keys` holds every selected id, including rows on other pages or hidden by a filter, while `rows` only holds the selected rows present in `data`. In server mode that is the current page, so an export built from `rows` silently drops everything selected on page 1 once you are on page 3.

While rows are selected the [Toolbar](https://wingo-ui.com/components/toolbar) switches to its bulk mode: an inline band on desktop, a floating bar on phones. The rest follows Gmail. Selecting a whole page in client mode shows a banner that offers every matching row, and rows that a new filter hides stay selected, with the banner saying how many. Shift-click selects a range, Ctrl or Cmd + A does what the header checkbox does, Escape clears. A bulk action that returns a promise shows its button loading until it settles, and a `danger` tone stays red, the floating phone bar included. For "Delete 240 clients" we still open an [Alert Dialog](https://wingo-ui.com/components/alert-dialog) from `onSelect`: a bulk delete deserves one more tap.

Clear the selection after an action removes rows. The table never drops a selected id on its own, since a missing id may sit on another page, so after a delete the bar would still count rows that no longer exist. That is why the example above controls `selectedKeys`.

## How do you set up server-side pagination in TanStack Table?

Tell the table the server owns the work, give it the total, and fetch whenever the state changes. TanStack Table server side pagination in v9 is three flags and a count, and the row model factories can go, because the browser never sorts or slices:

```ts
const serverFeatures = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowPaginationFeature });
// columns come from createColumnHelper<typeof serverFeatures, Client>()
const NO_ROWS: Client[] = [];

// in the component: result is your fetch for { pagination, sorting, columnFilters }
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useTable({
  features: serverFeatures,
  columns,
  data: result.data?.rows ?? NO_ROWS,
  rowCount: result.data?.total,
  state: { pagination, sorting, columnFilters },
  onPaginationChange: setPagination,
  onSortingChange: setSorting,
  onColumnFiltersChange: setColumnFilters,
  manualPagination: true,
  manualSorting: true,
  manualFiltering: true,
});
```

The Data Table folds that wiring into `mode="server"`: `data` is the current page, `rowCount` the total, and every change arrives in `onQueryChange` as one object with `page`, `pageSize`, `sorting`, `globalFilter` and `columnFilters`. That object is a ready-made query key for TanStack Query:

```tsx
// server-clients-table.tsx
"use client";

import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { DataTable, type DataTableQuery } from "@/components/ui/data-table";
import { deleteClients } from "./actions";
import { columns, type Client } from "./clients-table";

// must match the table's defaults: the first render does not call onQueryChange
const FIRST: DataTableQuery = { page: 1, pageSize: 25, sorting: [], globalFilter: "", columnFilters: [] };
const NO_ROWS: Client[] = [];

async function fetchClients(query: DataTableQuery): Promise<{ rows: Client[]; total: number }> {
  const res = await fetch(`/api/clients?q=${encodeURIComponent(JSON.stringify(query))}`);
  if (!res.ok) throw new Error(`Clients failed with ${res.status}`);
  return res.json();
}

export function ServerClientsTable() {
  const [query, setQuery] = useState(FIRST);
  const [selected, setSelected] = useState<string[]>([]);
  const { data, isPending, isFetching, error, refetch } = useQuery({
    queryKey: ["clients", query],
    queryFn: () => fetchClients(query),
    placeholderData: keepPreviousData,
  });

  return (
    <DataTable
      mode="server"
      data={data?.rows ?? NO_ROWS}
      rowCount={data?.total ?? 0}
      columns={columns}
      onQueryChange={setQuery}
      loading={isPending}
      refreshing={isFetching && !isPending}
      error={error ? "Clients could not load." : undefined}
      onRetry={() => refetch()}
      selectionMode="multiple"
      selectedKeys={selected}
      onSelectedKeysChange={setSelected}
      bulkActions={(_rows, keys) => [
        {
          label: "Delete",
          icon: <Trash2 />,
          tone: "danger",
          onSelect: async () => {
            await deleteClients(keys);
            setSelected([]);
            await refetch();
          },
        },
      ]}
      caption="Clients"
      locale="en-US"
    />
  );
}
```

`keepPreviousData` keeps the last page on screen while the next one loads, and `refreshing` slides a thin bar along the top edge instead of swapping the rows for skeletons. The search arrives debounced (300ms), and the page goes back to 1 when the search, a filter or the sort changes.

The route handler treats the query as untrusted input. Sort ids and page sizes come from a whitelist, and each filter id maps to a clause you wrote:

```ts
// app/api/clients/route.ts
import type { NextRequest } from "next/server";
import type { DataTableQuery } from "@/components/ui/data-table";
import { findClients } from "@/server/clients";

const SORTABLE = new Set(["name", "city", "status", "owner", "balance", "lastInvoice"]);
const PAGE_SIZES = [10, 25, 50, 100];

export async function GET(request: NextRequest) {
  let raw: Partial<DataTableQuery> = {};
  try {
    raw = JSON.parse(request.nextUrl.searchParams.get("q") ?? "{}");
  } catch {}
  const pageSize = PAGE_SIZES.includes(Number(raw.pageSize)) ? Number(raw.pageSize) : 25;
  const page = Math.max(1, Math.floor(Number(raw.page)) || 1);
  const first = (Array.isArray(raw.sorting) ? raw.sorting : []).find((s) => SORTABLE.has(s?.id));
  const sort = first ? { id: first.id, desc: first.desc === true } : undefined;
  // findClients checks the session and maps each filter id to a known SQL clause
  const { rows, total } = await findClients({
    offset: (page - 1) * pageSize,
    limit: pageSize,
    sort,
    search: String(raw.globalFilter ?? "").slice(0, 100),
    filters: Array.isArray(raw.columnFilters) ? raw.columnFilters : [],
  });
  return Response.json({ rows, total });
}
```

Two server-mode details. The table has no faceted values without the full data, so a select filter lists only its `filterOptions`, or the badge map's keys on a badge column: `status` works as is, while `city` and `owner` need an explicit `filterOptions` list (a `select distinct` on the server, for example). And quick filters need a `columnFilter`, since a `filter` function never reaches the server. If the page, sort and filters should live in the URL so a link reopens the same view, the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) does that with `router.replace` and a server component.

> Live demo (Data Table): Sort, search, or select rows on page 1 and then on page 2 (long press a card on a phone): the query object the server receives is printed under the table, and the selection count keeps the rows from both pages. Try it at [Data Table](https://wingo-ui.com/components/data-table) and install it with `npx wingo-ui@latest add data-table`.

Server mode has a cost: every pause in typing is a request. When one request can load every row a user may see, client mode filters instantly and needs no query API. We switch past a few thousand rows, or when permissions decide which rows exist for a user.

## What should a data table look like on a phone?

A card per row. Eight columns on a 390px screen show about two at a time, and the header that names them scrolls out of sight. Below 768px the Data Table renders each row as a card, built from the same `mobile` roles you saw in the columns:

- `title` is the card heading (by default the first column), `subtitle` the line under it.
- `meta` sits in the top corner (by default the first badge or the last number column).
- Up to four `field` columns show as label and value pairs; `hidden` keeps a column off the card.

Long press a card to start selecting, then tap to add more, and the bulk bar floats at the bottom of the screen. Sort, filters and the row menu open as bottom sheets, and client mode appends rows with Load more while server mode keeps a compact [Pagination](https://wingo-ui.com/components/pagination) pager. The switch is a CSS breakpoint, so the server HTML already has the right layout before hydration and nothing jumps. `renderCard` replaces the mapping with your own card, and `mobileLayout="scroll"` keeps the table scrolling sideways for the rare grid that people compare column by column. The reasoning behind those defaults, with 44px touch targets and bottom sheets, is in [mobile-first React components](https://wingo-ui.com/blog/mobile-first-react-components). How to pick the columns for a card, and the same layout on a plain shadcn/ui table, are in [responsive tables on mobile](https://wingo-ui.com/blog/responsive-table-mobile-cards).

For small read-only tables, such as invoice lines or a price list, the plain [Table](https://wingo-ui.com/components/table) does the same card switch without TanStack, selection or a toolbar.

## Should you build on TanStack directly or use a component?

Build directly when the table is one of a kind or when your design system already has the menus and sheets; use a component when you need the same admin table on twenty screens. The options we would consider, as of October 2026:

| Option | License and price | Server mode | Bulk actions | Pinning | Phone layout |
| --- | --- | --- | --- | --- | --- |
| [TanStack Table v9](https://tanstack.com/table/latest) alone | MIT, free | State only | State only | State only | Yours |
| [shadcn data table guide](https://ui.shadcn.com/docs/components/data-table) | MIT, free | Linked to TanStack docs | Selected-row count | No | No |
| [tablecn](https://github.com/sadmann7/tablecn) | MIT, free | Yes, with Drizzle and PostgreSQL | Action bar on selection | Not listed | Not listed |
| Wingo UI Data Table | Pro: $8 a month, $80 a year or $150 lifetime | Yes (`mode="server"`) | Yes, floating on phones | Yes, plus header menu | Cards below 768px |

If you want Notion-style advanced filters over a Postgres table, tablecn is the better free starting point. If you only need sorting, one text filter and client pagination, the shadcn guide is enough. The Data Table, with the Table, Pagination and Toolbar it builds on, is part of [Wingo UI Pro](https://wingo-ui.com/pricing).

## What should you build next?

Open the playground on the [Data Table docs](https://wingo-ui.com/components/data-table), switch `mode` to server and watch the query object change as you sort and filter, then install it with `npx wingo-ui@latest add data-table` after a one-time [CLI login](https://wingo-ui.com/docs/cli). Other tables in the library, from the members table to the editable grid, are listed under [tables](https://wingo-ui.com/components/category/tables), and more component tutorials live in the [component guides](https://wingo-ui.com/blog/category/components) category, starting with the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) for the filter forms around your tables.

## Components in this post

- [Data Table](https://wingo-ui.com/components/data-table) (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: `npx wingo-ui@latest add data-table`
- [Table](https://wingo-ui.com/components/table) (Pro): The library's one table look: semantic table parts plus a props API for small read-only tables that turn into cards on phones. Install: `npx wingo-ui@latest add table`
- [Pagination](https://wingo-ui.com/components/pagination) (Pro): Pages for tables and lists: numbers with ellipses, previous / next, page size, jump to page, a compact phone pager and load more. Install: `npx wingo-ui@latest add pagination`
- [Toolbar](https://wingo-ui.com/components/toolbar) (Pro): The bar above a list or table: search, filter chips, applied filters, sort, a list / grid switch, the count, actions and a bulk mode. Install: `npx wingo-ui@latest add toolbar`

## FAQ

### What replaced useReactTable in TanStack Table v9?

useTable, which takes a required features option built with tableFeatures(). Row models such as sorting and pagination moved into that features object as factories (sortedRowModel: createSortedRowModel()), and useLegacyTable from @tanstack/react-table/legacy keeps the v8 API as a deprecated migration aid.

### How do I do server-side pagination in TanStack Table v9?

Set manualPagination: true, pass the total as rowCount, control the pagination state and fetch the page your API returns for it. Add manualSorting and manualFiltering when the server also sorts and filters, and leave the matching row model factories out of tableFeatures, since the browser never runs them.

### How do I pin columns in TanStack Table v9?

Register columnPinningFeature and set columnPinning to { start: [...], end: [...] }; v9 renamed left and right to start and end with no aliases. Register columnSizingFeature too, make the pinned cells position: sticky with column.getStart('start') or column.getAfter('end') as the offset, and give them a width and a background so the offsets line up and rows do not show through.

### Does the shadcn data table work on phones?

As of October 2026 the shadcn data table guide covers sorting, filtering, column visibility, client pagination and row selection on TanStack Table v9, and has no phone layout: the table keeps its columns and scrolls sideways. A card per row below 768px is something you add yourself or get from a component that ships it.

### Is the Wingo UI Data Table free?

No. The Data Table is part of Wingo UI Pro, and so are the Table, Pagination and Toolbar it builds on. The checkbox, dropdown menu, popover and other basics it uses are free on their own.

---

Source: https://wingo-ui.com/blog/tanstack-table-v9-data-table
