Back to Blog

React Data Tables: TanStack Table v8, Server-Side Sorting, Filtering, and Pagination

Build production data tables in React with TanStack Table v8. Covers server-side sorting, filtering, and cursor pagination, column visibility, row selection with bulk actions, column pinning, and virtualized rows with TanStack Virtual.

Viprasol Tech Team
May 19, 2027
13 min read

TanStack Table v8 is headless β€” it handles all the logic (sorting, filtering, pagination, selection) but provides zero UI. You own the markup and styling. This sounds like more work, but it means the table looks like your design system, not a library's opinionated components.

This guide covers the full server-side pattern: URL-synchronized state, server-side sort/filter/paginate, row selection, and bulk actions.

Column Definitions

// components/invoices/columns.tsx
import { createColumnHelper } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/format";
import { InvoiceActionsMenu } from "./invoice-actions-menu";

export interface InvoiceRow {
  id:           string;
  number:       string;
  clientName:   string;
  amountCents:  number;
  status:       "draft" | "sent" | "paid" | "overdue";
  dueDate:      string;
  createdAt:    string;
}

const STATUS_BADGE: Record<InvoiceRow["status"], { label: string; variant: string }> = {
  draft:   { label: "Draft",   variant: "secondary" },
  sent:    { label: "Sent",    variant: "info" },
  paid:    { label: "Paid",    variant: "success" },
  overdue: { label: "Overdue", variant: "destructive" },
};

const col = createColumnHelper<InvoiceRow>();

export const invoiceColumns = [
  // Row selection checkbox
  col.display({
    id:     "select",
    header: ({ table }) => (
      <input
        type="checkbox"
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected()}
        onChange={table.getToggleAllPageRowsSelectedHandler()}
        aria-label="Select all"
        className="w-4 h-4 rounded"
      />
    ),
    cell: ({ row }) => (
      <input
        type="checkbox"
        checked={row.getIsSelected()}
        onChange={row.getToggleSelectedHandler()}
        aria-label={`Select invoice ${row.original.number}`}
        className="w-4 h-4 rounded"
      />
    ),
    size:       40,
    enableSorting: false,
  }),

  col.accessor("number", {
    header:     "Invoice",
    cell:       (info) => <span className="font-mono text-sm">{info.getValue()}</span>,
    enableSorting: true,
  }),

  col.accessor("clientName", {
    header:     "Client",
    enableSorting: true,
  }),

  col.accessor("amountCents", {
    header:     "Amount",
    cell:       (info) => formatCurrency(info.getValue()),
    enableSorting: true,
    meta:       { align: "right" },
  }),

  col.accessor("status", {
    header:        "Status",
    enableSorting: true,
    cell: (info) => {
      const config = STATUS_BADGE[info.getValue()];
      return <Badge variant={config.variant as any}>{config.label}</Badge>;
    },
  }),

  col.accessor("dueDate", {
    header:     "Due Date",
    cell:       (info) => formatDate(info.getValue()),
    enableSorting: true,
  }),

  // Actions column (no sorting)
  col.display({
    id:            "actions",
    header:        "",
    cell:          ({ row }) => <InvoiceActionsMenu invoice={row.original} />,
    enableSorting: false,
    size:          60,
  }),
];

URL-Synchronized Table State

// hooks/use-table-params.ts
"use client";

import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useCallback, useMemo } from "react";
import type { SortingState, ColumnFiltersState } from "@tanstack/react-table";

export interface TableParams {
  sorting:   SortingState;
  filters:   Record<string, string>;
  cursor?:   string;
  pageSize:  number;
}

export function useTableParams(): {
  params:    TableParams;
  setSorting: (sorting: SortingState) => void;
  setFilter:  (column: string, value: string) => void;
  setCursor:  (cursor: string | undefined) => void;
} {
  const router     = useRouter();
  const pathname   = usePathname();
  const searchParams = useSearchParams();

  const params = useMemo((): TableParams => {
    const sortBy  = searchParams.get("sortBy");
    const sortDir = searchParams.get("sortDir") ?? "asc";
    const cursor  = searchParams.get("cursor") ?? undefined;
    const pageSize = parseInt(searchParams.get("pageSize") ?? "25");

    return {
      sorting:  sortBy ? [{ id: sortBy, desc: sortDir === "desc" }] : [],
      filters:  Object.fromEntries(
        Array.from(searchParams.entries()).filter(([k]) =>
          !["sortBy", "sortDir", "cursor", "pageSize"].includes(k)
        )
      ),
      cursor,
      pageSize,
    };
  }, [searchParams]);

  function updateParams(updates: Partial<Record<string, string | undefined>>) {
    const current = new URLSearchParams(searchParams.toString());
    Object.entries(updates).forEach(([k, v]) => {
      if (v === undefined) current.delete(k);
      else current.set(k, v);
    });
    // Reset cursor when sorting/filtering changes
    router.push(`${pathname}?${current.toString()}`);
  }

  const setSorting = useCallback((sorting: SortingState) => {
    const s = sorting[0];
    updateParams({
      sortBy:  s ? s.id : undefined,
      sortDir: s ? (s.desc ? "desc" : "asc") : undefined,
      cursor:  undefined, // Reset pagination
    });
  }, [searchParams]);

  const setFilter = useCallback((column: string, value: string) => {
    updateParams({ [column]: value || undefined, cursor: undefined });
  }, [searchParams]);

  const setCursor = useCallback((cursor: string | undefined) => {
    updateParams({ cursor });
  }, [searchParams]);

  return { params, setSorting, setFilter, setCursor };
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

InvoicesTable Component

// components/invoices/invoices-table.tsx
"use client";

import {
  useReactTable,
  getCoreRowModel,
  flexRender,
  type SortingState,
  type RowSelectionState,
} from "@tanstack/react-table";
import { useState, useCallback } from "react";
import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react";
import { invoiceColumns, type InvoiceRow } from "./columns";
import { useTableParams } from "@/hooks/use-table-params";
import { BulkActionBar } from "./bulk-action-bar";

interface InvoicesTableProps {
  data:        InvoiceRow[];
  totalCount:  number;
  nextCursor?: string;
  prevCursor?: string;
}

export function InvoicesTable({
  data,
  totalCount,
  nextCursor,
  prevCursor,
}: InvoicesTableProps) {
  const { params, setSorting, setFilter, setCursor } = useTableParams();
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  const [columnVisibility, setColumnVisibility] = useState({});

  const table = useReactTable({
    data,
    columns:         invoiceColumns,
    state: {
      sorting:          params.sorting,
      rowSelection,
      columnVisibility,
    },
    // Server-side: tell TanStack Table we control sorting/filtering externally
    manualSorting:    true,
    manualFiltering:  true,
    manualPagination: true,

    onSortingChange:   (updater) => {
      const next = typeof updater === "function" ? updater(params.sorting) : updater;
      setSorting(next);
    },
    onRowSelectionChange:   setRowSelection,
    onColumnVisibilityChange: setColumnVisibility,
    getCoreRowModel: getCoreRowModel(),
    getRowId:        (row) => row.id,
  });

  const selectedIds = Object.keys(rowSelection);

  return (
    <div className="space-y-4">
      {/* Filters bar */}
      <div className="flex items-center gap-3">
        <input
          type="search"
          placeholder="Search clients…"
          defaultValue={params.filters.clientName ?? ""}
          onChange={(e) => setFilter("clientName", e.target.value)}
          className="border border-gray-200 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <select
          value={params.filters.status ?? ""}
          onChange={(e) => setFilter("status", e.target.value)}
          className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="">All statuses</option>
          <option value="draft">Draft</option>
          <option value="sent">Sent</option>
          <option value="paid">Paid</option>
          <option value="overdue">Overdue</option>
        </select>

        {/* Column visibility toggle */}
        <div className="relative ml-auto">
          <button className="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">
            Columns β–Ύ
          </button>
        </div>

        <span className="text-sm text-gray-500">{totalCount} invoices</span>
      </div>

      {/* Bulk action bar β€” appears when rows selected */}
      {selectedIds.length > 0 && (
        <BulkActionBar
          selectedIds={selectedIds}
          onClear={() => setRowSelection({})}
        />
      )}

      {/* Table */}
      <div className="border border-gray-200 rounded-xl overflow-hidden">
        <table className="w-full text-sm">
          <thead className="bg-gray-50 border-b border-gray-200">
            {table.getHeaderGroups().map((hg) => (
              <tr key={hg.id}>
                {hg.headers.map((header) => (
                  <th
                    key={header.id}
                    className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide"
                    style={{ width: header.getSize() }}
                  >
                    {header.column.getCanSort() ? (
                      <button
                        onClick={header.column.getToggleSortingHandler()}
                        className="flex items-center gap-1 hover:text-gray-900"
                      >
                        {flexRender(header.column.columnDef.header, header.getContext())}
                        {header.column.getIsSorted() === "asc"  && <ChevronUp className="w-3.5 h-3.5" />}
                        {header.column.getIsSorted() === "desc" && <ChevronDown className="w-3.5 h-3.5" />}
                        {!header.column.getIsSorted()           && <ChevronsUpDown className="w-3.5 h-3.5 text-gray-300" />}
                      </button>
                    ) : (
                      flexRender(header.column.columnDef.header, header.getContext())
                    )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody className="divide-y divide-gray-100">
            {table.getRowModel().rows.map((row) => (
              <tr
                key={row.id}
                className={`hover:bg-gray-50 transition-colors ${
                  row.getIsSelected() ? "bg-blue-50" : ""
                }`}
              >
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3 text-gray-900">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
            {data.length === 0 && (
              <tr>
                <td
                  colSpan={table.getAllColumns().length}
                  className="px-4 py-12 text-center text-sm text-gray-400"
                >
                  No invoices found
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>

      {/* Cursor pagination */}
      <div className="flex items-center justify-between">
        <span className="text-sm text-gray-500">
          {selectedIds.length > 0
            ? `${selectedIds.length} selected`
            : `Showing ${data.length} of ${totalCount}`}
        </span>
        <div className="flex gap-2">
          <button
            onClick={() => setCursor(prevCursor)}
            disabled={!prevCursor}
            className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
          >
            ← Previous
          </button>
          <button
            onClick={() => setCursor(nextCursor)}
            disabled={!nextCursor}
            className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
          >
            Next β†’
          </button>
        </div>
      </div>
    </div>
  );
}

Server-Side Data Fetching

// app/invoices/page.tsx
import { InvoicesTable } from "@/components/invoices/invoices-table";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import type { Prisma } from "@prisma/client";

interface PageProps {
  searchParams: {
    sortBy?:     string;
    sortDir?:    "asc" | "desc";
    cursor?:     string;
    clientName?: string;
    status?:     string;
  };
}

const VALID_SORT_COLUMNS = ["number", "clientName", "amountCents", "status", "dueDate"] as const;
type SortColumn = typeof VALID_SORT_COLUMNS[number];

export default async function InvoicesPage({ searchParams }: PageProps) {
  const session = await auth();
  const PAGE_SIZE = 25;

  // Validate sort column (prevent SQL injection via orderBy)
  const sortBy = VALID_SORT_COLUMNS.includes(searchParams.sortBy as SortColumn)
    ? (searchParams.sortBy as SortColumn)
    : "createdAt";
  const sortDir = searchParams.sortDir === "desc" ? "desc" : "asc";

  const where: Prisma.InvoiceWhereInput = {
    workspaceId: session!.user.workspaceId,
    ...(searchParams.clientName && {
      clientName: { contains: searchParams.clientName, mode: "insensitive" },
    }),
    ...(searchParams.status && { status: searchParams.status }),
  };

  const [invoices, totalCount] = await Promise.all([
    prisma.invoice.findMany({
      where,
      orderBy: { [sortBy]: sortDir },
      take:    PAGE_SIZE + 1,  // Fetch one extra to check if next page exists
      cursor:  searchParams.cursor ? { id: searchParams.cursor } : undefined,
      skip:    searchParams.cursor ? 1 : 0,
      select: {
        id: true, number: true, clientName: true,
        amountCents: true, status: true, dueDate: true, createdAt: true,
      },
    }),
    prisma.invoice.count({ where }),
  ]);

  const hasNextPage = invoices.length > PAGE_SIZE;
  const page        = hasNextPage ? invoices.slice(0, PAGE_SIZE) : invoices;
  const nextCursor  = hasNextPage ? page[page.length - 1].id : undefined;

  return (
    <div className="max-w-7xl mx-auto py-8 px-4">
      <h1 className="text-2xl font-bold text-gray-900 mb-6">Invoices</h1>
      <InvoicesTable
        data={page}
        totalCount={totalCount}
        nextCursor={nextCursor}
      />
    </div>
  );
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic table with client-side sort1 dev1–2 days$400–800
Server-side sort + filter + cursor pagination1–2 devs3–4 days$1,200–2,400
+ Row selection + bulk actions + column visibility1 dev+ 2 days$600–1,200

See Also


Working With Viprasol

Data tables are deceptively complex β€” server-side sort/filter/paginate with URL synchronization, bulk actions that don't lose selection state on refresh, and column visibility that persists per user. Our team builds TanStack Table v8 integrations with headless logic, your design system's markup, cursor-based pagination, and full URL synchronization so users can share filtered table views.

What we deliver:

  • createColumnHelper column definitions with select checkbox, sortable accessors, action menu
  • useTableParams hook: URL-synchronized sorting, filters, and cursor from useSearchParams
  • InvoicesTable: manualSorting/Filtering/Pagination, onSortingChange, row selection state
  • Sort indicators: ChevronUp/ChevronDown/ChevronsUpDown per column
  • Server page: Prisma cursor pagination, VALID_SORT_COLUMNS whitelist, parallel count query
  • BulkActionBar with selectedIds and clear selection

Talk to our team about your data table requirements β†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.