TanStack TableReact TableData GridTypeScriptSaaS UI

Building Data-Rich SaaS Interfaces with TanStack Table

A practical guide to TanStack Table (React Table) for building data-rich interfaces — covering sorting, filtering, pagination, row selection, column visibility, and server-side data integration for SaaS applications.

Sam Rivera
Sam Rivera
June 11, 202613 min read
Also available in:Deutsch · 中文

TL;DR: TanStack Table (formerly React Table) is a headless UI library for building high-performance data grids and tables. It handles sorting, filtering, pagination, row selection, column ordering, and column visibility — all with full TypeScript support and zero design opinion. This guide covers real-world SaaS patterns, including server-side pagination with D1, inline editing, export to CSV, and responsive columns for mobile.


Why TanStack Table?

Every SaaS application eventually needs a table — users, invoices, subscriptions, analytics. Building one from scratch seems simple until you need sorting, filtering, pagination, and column resizing. TanStack Table solves all of these without dictating your UI:

FeatureTanStack TableAG GridMUI Data GridCustom
Bundle size~18 KB~500 KB~120 KBVaries
Headless✅❌❌✅
TypeScript✅ FullPartialPartialManual
Virtual scrolling✅✅✅Complex
Column resizing✅✅✅Complex
Row selection✅✅✅Manual
Export/CSVManual (easy)✅✅Manual
PriceFree$999+/yrFreeDev time
Custom renderingCompleteLimitedLimitedComplete

Basic Setup

Install the package with your preferred package manager:

bash
npm install @tanstack/react-table

Minimal Data Table

Below is a complete subscription management table. It demonstrates the core TanStack Table concepts: type-safe columns via the column helper, built-in sorting, filtering, and pagination:

typescript
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  createColumnHelper,
  flexRender,
} from "@tanstack/react-table"

// 1. Define your data type
type Subscription = {
  id: string
  customerEmail: string
  plan: string
  status: "active" | "canceled" | "past_due"
  mrr: number
  createdAt: string
}

// 2. Create column helper for type-safe columns
const columnHelper = createColumnHelper<Subscription>()

const columns = [
  columnHelper.accessor("customerEmail", {
    header: "Customer",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("plan", {
    header: "Plan",
    cell: (info) => (
      <span className="capitalize">{info.getValue()}</span>
    ),
  }),
  columnHelper.accessor("status", {
    header: "Status",
    cell: (info) => {
      const status = info.getValue()
      const colors = {
        active: "text-green-600",
        canceled: "text-gray-400",
        past_due: "text-red-600",
      }
      return <span className={colors[status]}>{status}</span>
    },
    filterFn: "equalsString",
  }),
  columnHelper.accessor("mrr", {
    header: "MRR",
    cell: (info) => `$${info.getValue().toFixed(2)}`,
    sortingFn: "basic",
  }),
  columnHelper.accessor("createdAt", {
    header: "Created",
    cell: (info) => new Date(info.getValue()).toLocaleDateString(),
  }),
]

// 3. Create the table instance
function SubscriptionTable({ data }: { data: Subscription[] }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: {
      sorting: [{ id: "mrr", desc: true }],
      pagination: { pageSize: 25 },
    },
  })

  return (
    <div>
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id} onClick={header.column.getToggleSortingHandler()}>
                  {flexRender(header.column.columnDef.header, header.getContext())}
                  {{ asc: " ↑", desc: " ↓" }[header.column.getIsSorted() as string] ?? null}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {/* Pagination */}
      <div className="flex items-center gap-4 mt-4">
        <button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>
          {"<<"}
        </button>
        <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
          {"<"}
        </button>
        <span>
          Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
        </span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
          {">"}
        </button>
        <button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()}>
          {">>"}
        </button>
        <select
          value={table.getState().pagination.pageSize}
          onChange={(e) => table.setPageSize(Number(e.target.value))}
        >
          {[10, 25, 50, 100].map((size) => (
            <option key={size} value={size}>{size} per page</option>
          ))}
        </select>
      </div>
    </div>
  )
}

The SubscriptionTable component is fully functional with sorting, filtering, and pagination built in. The pagination UI includes page navigation buttons, a page indicator, and a configurable page size selector — all connected to the table instance without any additional state management.


Server-Side Pagination with Cloudflare D1

For SaaS applications with thousands of records, load data from the server instead of the client. Here's a server function that handles paginated, sorted queries against D1:

typescript
// server/subscriptions.ts
import { createServerFn } from "@tanstack/react-start"

interface PaginatedQuery {
  page: number
  pageSize: number
  sortField: string
  sortDir: "asc" | "desc"
  filterStatus?: string
}

export const getSubscriptions = createServerFn({ method: "GET" }).handler(
  async ({ data }: { data: PaginatedQuery }) => {
    const offset = data.page * data.pageSize

    // Build query with sorting and filtering
    const whereClause = data.filterStatus
      ? "WHERE status = ?"
      : ""

    const countResult = await env.DB.prepare(
      `SELECT COUNT(*) as total FROM subscriptions ${whereClause}`
    )
      .bind(...(data.filterStatus ? [data.filterStatus] : []))
      .first() as { total: number }

    const rows = await env.DB.prepare(
      `SELECT id, customer_email, plan, status, mrr, created_at
       FROM subscriptions ${whereClause}
       ORDER BY ${data.sortField} ${data.sortDir}
       LIMIT ? OFFSET ?`
    )
      .bind(...(data.filterStatus ? [data.filterStatus] : []), data.pageSize, offset)
      .all()

    return {
      data: rows.results,
      total: countResult.total,
      page: data.page,
      pageSize: data.pageSize,
    }
  }
)

The server function runs entirely on Cloudflare Workers, executing SQL directly against D1. It returns both the paginated rows and the total count, allowing the client to render accurate pagination controls.

typescript
// components/SubscriptionTable.tsx — server-side pagination
import { useQuery } from "@tanstack/react-query"
import { useReactTable, getCoreRowModel } from "@tanstack/react-table"
import { getSubscriptions } from "../server/subscriptions"

function ServerSideTable() {
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 })
  const [sorting, setSorting] = useState<SortingState>([])

  const query = useQuery({
    queryKey: ["subscriptions", pagination, sorting],
    queryFn: () =>
      getSubscriptions({
        data: {
          page: pagination.pageIndex,
          pageSize: pagination.pageSize,
          sortField: sorting[0]?.id ?? "created_at",
          sortDir: sorting[0]?.desc ? "desc" : "asc",
        },
      }),
  })

  const table = useReactTable({
    data: query.data?.data ?? [],
    columns,
    pageCount: Math.ceil((query.data?.total ?? 0) / pagination.pageSize),
    state: { pagination, sorting },
    onPaginationChange: setPagination,
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    manualSorting: true,
  })
  // ... render
}

After integrating with useQuery, the table receives data from the server. The manualPagination and manualSorting flags tell TanStack Table to rely entirely on server responses for pagination and sorting state.


Row Selection and Bulk Actions

TanStack Table supports built-in row selection with checkboxes. Here's how to enable bulk actions like canceling or exporting selected subscriptions:

typescript
function BulkActionsTable() {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    enableRowSelection: true,
    onRowSelectionChange: setRowSelection,
    state: { rowSelection },
    getRowId: (row) => row.id,
  })

  const selectedIds = Object.keys(rowSelection)

  return (
    <div>
      {selectedIds.length > 0 && (
        <div className="bg-blue-50 p-3 rounded mb-4 flex gap-2 items-center">
          <span>{selectedIds.length} selected</span>
          <button onClick={() => bulkCancel(selectedIds)} className="...">
            Cancel Subscriptions
          </button>
          <button onClick={() => bulkExport(selectedIds)} className="...">
            Export Selected
          </button>
        </div>
      )}
      {/* Table rendering */}
    </div>
  )
}

When rows are selected, a floating action bar appears with bulk operation buttons. The rowSelection state is managed internally by TanStack Table and provides the selected row IDs for your mutation functions.


Column Visibility Toggle

Let users customize their view by toggling column visibility. Here's a dropdown component that renders a checkbox for each hideable column:

typescript
function ColumnToggle({ table }: { table: Table<Subscription> }) {
  const columns = table.getAllLeafColumns()

  return (
    <div className="dropdown">
      <button>Columns</button>
      <div className="dropdown-content">
        {columns
          .filter((col) => col.getCanHide())
          .map((col) => (
            <label key={col.id} className="flex items-center gap-2">
              <input
                type="checkbox"
                checked={col.getIsVisible()}
                onChange={col.getToggleVisibilityHandler()}
              />
              {col.columnDef.header as string}
            </label>
          ))}
      </div>
    </div>
  )
}

The column toggle component filters out columns that can't be hidden (getCanHide()) and allows users to show or hide individual columns. This preference can be persisted to localStorage for return visits.


Export to CSV

For data analysis workflows, provide a CSV export function. This utility converts an array of objects to a downloadable CSV file:

typescript
function exportToCsv<T extends Record<string, unknown>>(
  rows: T[],
  filename: string
) {
  const headers = Object.keys(rows[0])
  const csvContent = [
    headers.join(","),
    ...rows.map((row) =>
      headers
        .map((h) => JSON.stringify(row[h] ?? ""))
        .join(",")
    ),
  ].join("
")

  const blob = new Blob([csvContent], { type: "text/csv" })
  const url = URL.createObjectURL(blob)
  const a = document.createElement("a")
  a.href = url
  a.download = `${filename}.csv`
  a.click()
  URL.revokeObjectURL(url)
}

The function extracts headers from the first row's keys, maps each row to comma-separated values with JSON stringification for safe escaping, and triggers a browser download via a dynamically created anchor element.


Responsive Columns

For mobile-friendly tables, hide less important columns using a meta field. Here's how to mark columns for responsive hiding:

typescript
const columns = [
  columnHelper.accessor("customerEmail", { header: "Customer" }),
  columnHelper.accessor("plan", {
    header: "Plan",
    meta: { hideOnMobile: true },
  }),
  columnHelper.accessor("status", { header: "Status" }),
  columnHelper.accessor("mrr", {
    header: "MRR",
    meta: { hideOnMobile: true },
  }),
]

// Use CSS to hide columns with `hideOnMobile`
// @media (max-width: 768px) {
//   th[data-hide-on-mobile="true"],
//   td[data-hide-on-mobile="true"] {
//     display: none;
//   }
// }

The meta: { hideOnMobile: true } annotation is a custom field that you can use in CSS/media queries. Combined with a data-attribute on cells, this lets you hide non-critical columns on small screens without losing TanStack Table's column management.


Performance: Virtual Scrolling

For tables with 10,000+ rows, combine TanStack Table with TanStack Virtual. The virtualizer controls which rows are rendered while the table manages column logic:

typescript
import { useReactTable, getCoreRowModel } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"

function VirtualizedTable({ data, columns }: Props) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  const { rows } = table.getRowModel()
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // Row height in pixels
    overscan: 10,
  })

  return (
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        <table>
          <thead>{/* sticky header */}</thead>
          <tbody>
            {virtualizer.getVirtualItems().map((virtualRow) => {
              const row = rows[virtualRow.index]
              return (
                <tr
                  key={row.id}
                  style={{
                    position: "absolute",
                    top: 0,
                    transform: `translateY(${virtualRow.start}px)`,
                    height: `${virtualRow.size}px`,
                  }}
                >
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              )
            })}
          </tbody>
        </table>
      </div>
    </div>
  )
}

The useVirtualizer only renders ~30 rows regardless of the total dataset size. The table provides the row data and column definitions, while the virtualizer handles which rows to render based on scroll position. This combination handles 100,000+ row datasets with no performance degradation.


Production Checklist

  • Server-side pagination for datasets over 500 rows
  • Column sorting with visual indicators (asc/desc)
  • Column visibility toggle with persisted preference
  • Row selection for bulk actions (cancel, export, delete)
  • Responsive columns that adapt to mobile viewports
  • Loading skeleton states during data fetch
  • Empty state when no data matches filters
  • Error state with retry button
  • Export to CSV for data analysis
  • Keyboard navigation (arrow keys, tab, enter)
  • Accessible labels for screen readers

Conclusion

TanStack Table is the foundation of data display in modern SaaS applications. Its headless architecture means you get powerful features — sorting, filtering, pagination, selection — without fighting a pre-designed UI. Combined with TanStack Virtual for performance and TanStack Query for data fetching, it handles everything from a 10-row admin panel to a 100,000-row analytics dashboard.

See these patterns in production at tanstackship.com.

For more on building data-rich interfaces, explore the Virtual Scrolling Guide for performance optimization, the Form Complete Guide for type-safe form validation, the Query + Server Functions Integration Guide for data fetching patterns, and the TanStack Ecosystem Overview 2026 for the full framework landscape.