Rendering a 100,000 row table in the browser is the fastest way to turn a nice dashboard into a frozen tab. The fix is not virtualization alone: it is moving pagination, sorting and filtering into the database and letting the UI request only the 25 rows it actually needs.
This tutorial shows how to wire TanStack Table server side pagination to a Next.js Route Handler, add column sorting and multi-field filtering that run in SQL, and sync everything to the URL so a page refresh (or a shared link) restores the exact same view.
What you will build
- A Next.js API route that accepts page, pageSize, sort and filter params and returns rows plus a total count.
- A TanStack Table v8 instance in manual mode (
manualPagination,manualSorting,manualFiltering). - TanStack Query caching with
placeholderDataso pages do not flash empty while loading. - URL state sync so filters, sort and page survive a refresh, a back button press, or a copy-paste of the link.
- Row selection that survives page changes, which is the single most reported pain point with server-driven tables.
- Database tips (indexes, stable sort keys, keyset pagination) that keep queries fast past page 500.

Client-side vs server-side: when to switch
TanStack Table is headless, so it happily sorts and filters in memory. That is great until the payload grows. Here is the rough decision table we use on client projects:
| Dataset size | Recommended approach | Why |
|---|---|---|
| Under 2,000 rows | Client-side everything | One fetch, instant sorting, zero backend work |
| 2,000 to 20,000 rows | Client-side + virtualization | Payload is still acceptable, DOM is the bottleneck |
| 20,000+ rows | Server-side pagination, sorting, filtering | JSON parsing and in-memory sorts block the main thread |
| 100,000+ rows | Server-side + indexed queries + cached counts | Even the COUNT(*) becomes a cost you must manage |
A 100k row response with 12 columns is typically 30 to 60 MB of JSON. The browser will download it, parse it, keep it in memory, and then hand it to React. That is where the freeze comes from, not from the table library.
Prerequisites
- Next.js App Router (15 or 16), React 19
@tanstack/react-tablev8.20 or newer (therowCounttable option used below landed in 8.13)@tanstack/react-queryv5- Any database layer. Examples use Prisma, but the logic maps one-to-one to Drizzle, Kysely or raw SQL.
Step 1: Define the request and response contract first
Before touching the table, lock down the shape of what the server accepts and returns. Everything else follows from this.
// types/table.ts
export type SortDir = 'asc' | 'desc';
export type UsersQuery = {
pageIndex: number; // zero-based, matches TanStack Table
pageSize: number;
sortBy: string;
sortDir: SortDir;
q: string; // free text across name + email
status: string; // exact match filter
country: string; // exact match filter
};
export type UsersResponse = {
rows: UserRow[];
total: number; // total rows matching the filters
pageCount: number;
};
Two details that save hours later:
- Use zero-based
pageIndexon the wire. TanStack Table is zero-based, so converting in two places is one bug waiting to happen. - Always return
totalfor the filtered result set, not the table size. The pagination footer depends on it.

Step 2: Make the URL the single source of truth
This is the part most tutorials skip, and it is the reason users complain that “my filters disappear when I refresh”. Instead of keeping pagination and sorting in useState, derive them from useSearchParams and push changes back with router.replace.
// hooks/use-table-url-state.ts
'use client';
import { useCallback, useMemo } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
const DEFAULTS = {
pageIndex: 0,
pageSize: 25,
sortBy: 'createdAt',
sortDir: 'desc',
q: '',
status: '',
country: '',
};
export function useTableUrlState() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const state = useMemo(() => ({
pageIndex: Number(searchParams.get('pageIndex') ?? DEFAULTS.pageIndex),
pageSize: Number(searchParams.get('pageSize') ?? DEFAULTS.pageSize),
sortBy: searchParams.get('sortBy') ?? DEFAULTS.sortBy,
sortDir: (searchParams.get('sortDir') ?? DEFAULTS.sortDir) as 'asc' | 'desc',
q: searchParams.get('q') ?? '',
status: searchParams.get('status') ?? '',
country: searchParams.get('country') ?? '',
}), [searchParams]);
const patch = useCallback((next: Record<string, string | number | undefined>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(next)) {
if (value === undefined || value === '' || value === null) params.delete(key);
else params.set(key, String(value));
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, pathname, searchParams]);
return { ...state, patch };
}
Important: use router.replace and not router.push for pagination clicks, otherwise the back button walks the user through every page they visited. Keep push for meaningful filter changes if you want them in history.
Step 3: The Next.js Route Handler
The API route does three things: validate input, build a where clause, and run the data query and the count query in one transaction.
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// Allowlist: never interpolate a user string into an ORDER BY
const SORTABLE = new Set(['name', 'email', 'status', 'country', 'createdAt']);
const MAX_PAGE_SIZE = 100;
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const pageIndex = Math.max(0, Number(sp.get('pageIndex') ?? 0) || 0);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(sp.get('pageSize') ?? 25) || 25));
const rawSort = sp.get('sortBy') ?? 'createdAt';
const sortBy = SORTABLE.has(rawSort) ? rawSort : 'createdAt';
const sortDir = sp.get('sortDir') === 'asc' ? 'asc' : 'desc';
const q = (sp.get('q') ?? '').trim();
const status = sp.get('status') ?? '';
const country = sp.get('country') ?? '';
const where = {
AND: [
q
? { OR: [
{ name: { contains: q, mode: 'insensitive' as const } },
{ email: { contains: q, mode: 'insensitive' as const } },
] }
: {},
status ? { status } : {},
country ? { country } : {},
],
};
const [rows, total] = await prisma.$transaction([
prisma.user.findMany({
where,
// secondary key on id keeps the order stable across pages
orderBy: [{ [sortBy]: sortDir }, { id: 'asc' }],
skip: pageIndex * pageSize,
take: pageSize,
select: { id: true, name: true, email: true, status: true, country: true, createdAt: true },
}),
prisma.user.count({ where }),
]);
return NextResponse.json({
rows,
total,
pageCount: Math.ceil(total / pageSize),
});
}
Three rules for this route
- Allowlist sortable columns. A raw
sortBygoing into an ORDER BY is a classic injection vector, and with an ORM it simply throws at runtime. - Cap the page size. Without a cap, someone will call
?pageSize=100000and you are back to the frozen tab, plus a hot database. - Add a tie-breaker to the sort. Sorting by a non-unique column such as
statuswithout a secondary key means rows can repeat or vanish between pages.
Step 4: Fetch with TanStack Query and keep the previous page visible
// hooks/use-users-query.ts
'use client';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { UsersQuery, UsersResponse } from '@/types/table';
async function fetchUsers(params: UsersQuery): Promise<UsersResponse> {
const sp = new URLSearchParams(
Object.entries(params).reduce((acc, [k, v]) => {
if (v !== '' && v !== undefined) acc[k] = String(v);
return acc;
}, {} as Record<string, string>)
);
const res = await fetch(`/api/users?${sp.toString()}`);
if (!res.ok) throw new Error('Failed to load users');
return res.json();
}
export function useUsersQuery(params: UsersQuery) {
return useQuery({
queryKey: ['users', params],
queryFn: () => fetchUsers(params),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
placeholderData: keepPreviousData is what makes server-side pagination feel instant: the previous page stays on screen with a subtle loading indicator instead of collapsing to an empty table. Combine it with a prefetchQuery on the next page index if you want near zero perceived latency.

Step 5: Put TanStack Table in manual mode
In manual mode the table stops doing the work and just reflects what the server returned. Three flags and one row count:
// components/users-table.tsx
'use client';
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type OnChangeFn,
type PaginationState,
type SortingState,
} from '@tanstack/react-table';
import { useMemo } from 'react';
import { useTableUrlState } from '@/hooks/use-table-url-state';
import { useUsersQuery } from '@/hooks/use-users-query';
const EMPTY: UserRow[] = [];
export function UsersTable({ columns }: { columns: ColumnDef<UserRow>[] }) {
const { pageIndex, pageSize, sortBy, sortDir, q, status, country, patch } = useTableUrlState();
const { data, isFetching } = useUsersQuery({ pageIndex, pageSize, sortBy, sortDir, q, status, country });
const pagination = useMemo<PaginationState>(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
const sorting = useMemo<SortingState>(() => [{ id: sortBy, desc: sortDir === 'desc' }], [sortBy, sortDir]);
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next = typeof updater === 'function' ? updater(pagination) : updater;
patch({ pageIndex: next.pageIndex, pageSize: next.pageSize });
};
const onSortingChange: OnChangeFn<SortingState> = (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
const first = next[0];
patch({
sortBy: first?.id ?? 'createdAt',
sortDir: first ? (first.desc ? 'desc' : 'asc') : 'desc',
pageIndex: 0, // always go back to page 1 when the order changes
});
};
const table = useReactTable({
data: data?.rows ?? EMPTY,
columns,
state: { pagination, sorting },
manualPagination: true,
manualSorting: true,
manualFiltering: true,
rowCount: data?.total ?? 0,
onPaginationChange,
onSortingChange,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
});
return (
<div style={{ opacity: isFetching ? 0.6 : 1 }}>
{/* render table.getHeaderGroups() and table.getRowModel().rows with flexRender */}
</div>
);
}
The four things that break here
- Do not add
getPaginationRowModel()orgetSortedRowModel(). WithmanualPaginationandmanualSortingthey will slice an already sliced page and you end up with one row on screen. - Pass
rowCountorpageCount. Without it, the table assumes it has every row andgetCanNextPage()returnsfalseon page 1. If you only know the page count, passpageCount. If the total is unknown, passpageCount: -1. - Never recreate
datainline.data={data?.rows ?? []}creates a new array on every render and triggers an infinite re-render loop. Hoist the empty array to a module constant, as above. - Reset
pageIndexto 0 whenever sorting or filters change, otherwise users land on page 43 of a 2 page result.
Step 6: Multi-field filtering without hammering the API
Free text filters must be debounced, otherwise every keystroke fires a query. Keep the input value in local state for responsiveness and push to the URL after a short delay.
'use client';
import { useEffect, useState } from 'react';
export function SearchInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [local, setLocal] = useState(value);
useEffect(() => setLocal(value), [value]);
useEffect(() => {
if (local === value) return;
const t = setTimeout(() => onCommit(local), 350);
return () => clearTimeout(t);
}, [local, value, onCommit]);
return <input value={local} onChange={(e) => setLocal(e.target.value)} placeholder='Search name or email' />;
}
Usage in the table component:
<SearchInput value={q} onCommit={(v) => patch({ q: v, pageIndex: 0 })} />
<select value={status} onChange={(e) => patch({ status: e.target.value, pageIndex: 0 })}>
<option value=''>All statuses</option>
<option value='active'>Active</option>
<option value='churned'>Churned</option>
</select>
Because every filter lives in the URL, a refresh, a shared link and the browser back button all restore the exact same view for free. That is also why the dataset works nicely with server components: you can read searchParams in the page and prefetch the first payload on the server.
Step 7: Row selection that survives pagination
This is the most common follow-up question in the TanStack issue tracker, and it has a simple answer: the table only knows about the rows currently loaded, so you must own the selection state yourself.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const rowSelection = useMemo(() => {
const out: Record<string, boolean> = {};
for (const id of selectedIds) out[id] = true;
return out;
}, [selectedIds]);
// in useReactTable options
state: { pagination, sorting, rowSelection },
enableRowSelection: true,
getRowId: (row) => row.id,
onRowSelectionChange: (updater) => {
const next = typeof updater === 'function' ? updater(rowSelection) : updater;
const pageIds = (data?.rows ?? []).map((r) => r.id);
setSelectedIds((prev) => {
const copy = new Set(prev);
for (const id of pageIds) {
if (next[id]) copy.add(id); else copy.delete(id);
}
return copy;
});
},
Two notes:
getRowIdis mandatory. Without it TanStack Table keys selection by row index, so row 0 on page 2 inherits the selection of row 0 on page 1.- For a “select all 83,412 matching rows” action, do not try to load the ids. Send the filter payload to a bulk endpoint and let the database resolve the set server-side.

Making the queries actually fast at 100k+ rows
1. Index every sortable and filterable column
An unindexed ORDER BY created_at DESC LIMIT 25 OFFSET 0 on 500k rows still sorts the whole table. Create composite indexes that match your common filter plus sort combinations, for example (status, created_at DESC, id).
2. Watch the COUNT query
On large tables the COUNT(*) is often slower than the data query itself. Options, from cheapest to most accurate:
| Strategy | Trade-off |
|---|---|
Exact COUNT(*) per request |
Accurate, gets slow past a few million rows |
| Count only when filters change, cache per filter hash | Great balance, one extra cache layer |
Approximate count (Postgres reltuples or EXPLAIN) |
Instant, footer shows “about 1.2M results” |
No count at all, fetch pageSize + 1 |
Fastest, but only next/previous navigation (pageCount: -1) |
3. Replace OFFSET with keyset pagination for deep pages
OFFSET 50000 forces the database to walk and discard 50,000 rows. If users genuinely browse deep (log viewers, audit trails), switch to cursor based navigation:
-- instead of: ORDER BY created_at DESC OFFSET 50000 LIMIT 25
SELECT * FROM users
WHERE (created_at, id) < ($lastCreatedAt, $lastId)
ORDER BY created_at DESC, id DESC
LIMIT 25;
The cost is that you lose “jump to page 2000”. In practice almost nobody does that, and most teams keep numbered pages for the first 50 pages and fall back to keyset beyond. The team at dev.to reached a similar conclusion.
4. Virtualize only when rows per page is high
If you ship 25 to 100 rows per page, plain DOM rendering is fine. Add @tanstack/react-virtual only if you let users pick 500 rows per page or build an infinite scrolling view.
Pre-launch checklist
- Sortable columns are validated against an allowlist on the server
pageSizeis capped server-side- Sorting includes a unique tie-breaker column
getPaginationRowModelandgetSortedRowModelare not registeredrowCount(orpageCount) is passed touseReactTabledatafalls back to a stable module-level empty array- Filter and sort changes reset
pageIndexto 0 - Text filters are debounced at 300 to 400 ms
- Pagination, sorting and filters are mirrored in the URL
getRowIdis set if you use row selection- Indexes exist for every filter plus sort combination you expose
FAQ
What is the difference between manualPagination and manualFiltering in TanStack Table?
manualPagination tells the table that the data array already represents a single page, so it should not slice it. manualFiltering and manualSorting do the same for filtering and ordering. You can mix them: server-side pagination with client-side column visibility is perfectly valid.
Do I need pageCount or rowCount?
Either one works. rowCount (total filtered rows) is the more convenient option because the table derives pageCount from it and your footer can show “1 to 25 of 83,412”. If your API cannot produce a total, set pageCount: -1 to enable next/previous navigation without a known end. Full details on https://github.com.
Why does my table show only one row after enabling server-side pagination?
Almost always because getPaginationRowModel() is still registered while manualPagination is true. The table paginates the already paginated page. Remove it.
Why is my component re-rendering infinitely?
A new array or object identity is passed into useReactTable on every render, usually data={rows ?? []} or an inline columns array. Memoize columns with useMemo and hoist the empty fallback array outside the component. Anyone digging further should read Vanilla TanStack Table Pagination Example.
Should I use TanStack Query or React Server Components to fetch the data?
Both work. The cleanest hybrid: read searchParams in the server component, prefetch the first page with queryClient.prefetchQuery, dehydrate it, and let TanStack Query handle every subsequent interaction on the client with keepPreviousData. You get a fast first paint and smooth client-side navigation.
How do I select all rows across every page?
Keep your own Set of selected ids and set getRowId. For a true “select everything that matches these filters” action, send the filter object to a dedicated bulk endpoint instead of transmitting tens of thousands of ids.
Does this work with the App Router and streaming?
Yes. Wrap the table in Suspense and remember that useSearchParams requires a client component inside a Suspense boundary, otherwise the route opts out of static rendering.
Wrapping up
TanStack Table server side pagination is mostly a contract problem, not a rendering problem. Once the API speaks the same language as the table (zero-based page index, one sort key, an explicit filter set, a total count) the client code becomes a thin layer: three manual flags, two change handlers, and the URL as your state store. The real engineering then moves where it belongs, into indexes, counts and query plans.
At Box Software we build data-heavy dashboards and internal tools on this exact stack. If you have a table that is struggling under its own weight, get in touch and we will take a look at it.
