TanStack TableServer-SideFilteringTypeScript

TanStack Table: Server-Side Filtering and Search

Build high-performance data tables with server-side filtering, sorting, and search using TanStack Table and TanStack Start server functions.

Sam Rivera
Sam Rivera
June 1, 202612 min read
Also available in:Deutsch · 中文

TL;DR: Client-side filtering breaks at scale. TanStack Table's manual filtering, sorting, and pagination modes let you delegate all data operations to the server. This guide covers server-side text search with D1, multi-column sort, filter state serialization to URL params, and debounced search input.


Introduction

TanStack Table (formerly React Table) is headless, meaning it manages state and logic while you control rendering. For production SaaS applications with thousands of records, you need server-side data operations. TanStack Table's manualFiltering, manualSorting, and manualPagination options are designed for exactly this.


Server Function with Filtering

First, define a server function that accepts search query, sort parameters, and pagination. Using Zod validation ensures type safety from the API boundary:

tsx
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '../db'

const searchSchema = z.object({
  query: z.string().default(''),
  sortBy: z.string().default('createdAt'),
  sortDir: z.enum(['asc', 'desc']).default('desc'),
  page: z.coerce.number().default(1),
  pageSize: z.coerce.number().default(25),
})

export const listCustomersFn = createServerFn({ method: 'GET' })
  .validator(searchSchema)
  .handler(async ({ data }) => {
    const { query, sortBy, sortDir, page, pageSize } = data
    const offset = (page - 1) * pageSize

    let dbQuery = db.selectFrom('customers').selectAll()

    if (query) {
      dbQuery = dbQuery.where((eb) =>
        eb.or([
          eb('name', 'like', `%${query}%`),
          eb('email', 'like', `%${query}%`),
        ])
      )
    }

    const [{ count }] = await dbQuery.select(db.fn.countAll().as('count')).execute()
    const rows = await dbQuery
      .orderBy(sortBy, sortDir)
      .limit(pageSize)
      .offset(offset)
      .execute()

    return { rows, total: count, page, pageSize }
  })

The server function handles search, sorting, and pagination in a single query. Using parameterized LIKE queries prevents SQL injection while enabling flexible text search across multiple columns.

TanStack Table Integration

With the server function ready, wire it up to TanStack Table using manualPagination and manualSorting modes. This tells the table to delegate data operations to the server:

tsx
import { useReactTable, getCoreRowModel, getFilteredRowModel } from '@tanstack/react-table'

function CustomerTable() {
  const [search, setSearch] = useState('')
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 })
  const [sorting, setSorting] = useState([{ id: 'createdAt', desc: true }])

  const { data, isFetching } = useQuery({
    queryKey: ['customers', search, pagination, sorting],
    queryFn: () => listCustomersFn({
      data: {
        query: search,
        page: pagination.pageIndex + 1,
        pageSize: pagination.pageSize,
        sortBy: sorting[0]?.id || 'createdAt',
        sortDir: sorting[0]?.desc ? 'desc' : 'asc',
      },
    }),
    placeholderData: keepPreviousData,
  })

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

  return <TableUI table={table} search={search} onSearchChange={setSearch} />
}

The manualPagination and manualSorting flags disable TanStack Table's built-in data processing. Every sorting change or page navigation triggers a new server query via TanStack Query, which handles caching and deduplication automatically.

Debounced Search

A debounce hook prevents excessive server requests while the user types. Here's a reusable implementation that delays the search query until the user stops typing:

tsx
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])
  return debounced
}

// Usage
const [searchInput, setSearchInput] = useState('')
const debouncedSearch = useDebounce(searchInput, 300)
// Query uses debouncedSearch, input uses searchInput

With 300ms debounce, the server receives at most one search request per 300ms of typing. The input remains responsive while avoiding unnecessary database queries.

Client vs Server Operations

OperationClient-SideServer-SideDecision
Text search<500 rows>500 rowsData volume
Sorting<1,000 rows>1,000 rowsData volume
Pagination<10 pagesAlwaysUX preference
Multi-column sortAny sizeLarge datasetsComplexity
Row groupingMedium datasetsLarge datasetsFeature need

Conclusion

Server-side filtering with TanStack Table scales from hundreds to millions of rows. Combined with TanStack Start server functions and D1, you get type-safe, debounced, and URL-persistent data tables that handle any dataset size. For a complete table implementation, see the Data-Rich Interfaces Guide, learn cursor-based pagination with Infinite Queries, design your database schema with the SaaS Database Architecture Guide, and combine virtual scrolling with TanStack Virtual Scrolling for datasets over 10,000 rows.