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:
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:
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:
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
| Operation | Client-Side | Server-Side | Decision |
|---|---|---|---|
| Text search | <500 rows | >500 rows | Data volume |
| Sorting | <1,000 rows | >1,000 rows | Data volume |
| Pagination | <10 pages | Always | UX preference |
| Multi-column sort | Any size | Large datasets | Complexity |
| Row grouping | Medium datasets | Large datasets | Feature 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.