Decision. If the server owns pagination + sorting + filtering and grouping/aggregation, only AG Grid’s Server-Side Row Model [12] and MUI X’s Data Source [18] are first-class engines that fetch, cache and lazy-load for you — both paywalled at the group/aggregate tier [13][20]. TanStack Table gives you
manual*opt-outs, not an engine: you own the state, the fetch, the debounce and the page-reset [1][5] — pair it with TanStack Query and it’s excellent, but the wiring is yours. Mantine React Table inherits exactly TanStack Table v8’s manual model and adds nothing server-side [24].
The axis that actually splits the field
There are two philosophies, and the word “supports server-side pagination” hides the difference:
- Opt-out model (TanStack Table, MRT): you flip
manualPagination/manualSorting/manualFilteringand the table stops doing the work. It does not start doing it on the server — it just believes whateverdataarray you hand it [1][2][3]. - Datasource model (AG Grid SSRM, MUI X Data Source): you implement one
getRows(params)callback; the grid owns the request lifecycle, block cache, scroll-triggered fetching, loading skeletons and error surface [12][14][18].
Feature matrix
| TanStack Table ⭐ 28.2k | AG Grid ⭐ 15.5k (SSRM) | MUI X DataGrid ⭐ 5.8k | Mantine React Table ⭐ 1.1k | |
|---|---|---|---|---|
| Server pagination | manualPagination + you supply rowCount/pageCount [1] |
✓ block-based, grid-driven [12] | ✓ paginationMode="server" or Data Source [21] |
manualPagination + rowCount [24] |
| Server sort/filter | manualSorting / manualFiltering [2][3] |
✓ sortModel/filterModel in request, cache auto-purged [15] |
✓ sortingMode/filterMode="server" [22] |
same as TanStack v8 [25] |
| Server grouping / aggregation | manualGrouping exists, but “not currently many known easy ways” [4] |
✓ lazy group expansion, pivot, aggregation — Enterprise [13] | ✓ aggregationModel + getAggregatedValue() — Premium [20] |
✗ inherits TanStack’s gap [4] |
| Infinite / lazy scroll | build it: Table + Query useInfiniteQuery + Virtual [7] |
✓ Infinite Row Model (free) or SSRM (Enterprise) [13] | ✓ lazyLoading prop — Pro [19] |
✗ build it |
| Built-in request cache | ✗ (bring TanStack Query) | ✓ block cache, cacheBlockSize 100, maxBlocksInCache LRU [15] |
✓ GridDataSourceCacheDefault, 5-min TTL, swappable [18] |
✗ |
| Built-in scroll debounce | ✗ | ✓ blockLoadDebounceMillis [15] |
✓ 500 ms viewport-loading throttle [19] | ✗ |
| Renders in RSC | ✗ 'use client' |
✗ 'use client' — depends on window/document [16] |
✗ 'use client' |
✗ 'use client' |
| Cost of server mode | free (your time) | Enterprise license for SSRM [13] | MIT for basic Data Source [23]; Pro/Premium for lazy load + aggregation [19][20] | free |
TanStack Table: what state you actually own
manualPagination: true makes the table use getPrePaginationRowModel and “assume that the data that you pass in is already paginated” [1]. You must then supply rowCount (the table derives pageCount from it) or pageCount: -1 for unknown totals — at which point getCanNextPage() always returns true [1].
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
const [sorting, setSorting] = useState<SortingState>([]);
const [filters, setFilters] = useState<ColumnFiltersState>([]);
const q = useQuery({
queryKey: ['rows', pagination, sorting, filters], // the key IS the server query
queryFn: () => fetchRows({ pagination, sorting, filters }),
placeholderData: keepPreviousData, // no flash-to-empty on page change
});
const table = useReactTable({
data: q.data?.rows ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualSorting: true, manualFiltering: true,
rowCount: q.data?.total,
state: { pagination, sorting, columnFilters: filters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setFilters,
});
Three sharp edges this snippet does not solve:
- Page reset is yours.
autoResetPageIndexis disabled automatically whenmanualPaginationis on [1]. Consequence: user on page 1000 types a filter → the table still requests page 1000 → empty results. Open since v8 as issue #4797 ⭐ 28.2k [5]. WrapsetFilters/setSortingso they alsosetPagination(p => ({...p, pageIndex: 0})). - Debounce is yours. Nothing in the table batches keystrokes; naively you “perform a request with every keystroke” [31].
- Grouping is effectively unsupported.
manualGroupingexists, but the official guide states there are “not currently many known easy ways to do server-side grouping with TanStack Table. You will need to do lots of custom cell rendering to make this work” [4]. If your table groups server-side, this alone disqualifies TanStack Table.
v9 status: beta since 7 June 2026, with TanStack Store-backed state, plugin-registered features and large memory/type-instantiation wins [6]. The V9 announcement says nothing about improving server-driven mode — the manual* opt-out philosophy is unchanged [6]. Adopting v9 in 2026 means adopting a beta.
AG Grid SSRM: the grid owns the request
You register one datasource; the grid calls getRows whenever it needs a block, and the request carries startRow/endRow, sortModel, filterModel, rowGroupCols, groupKeys, valueCols, pivotCols [12].
const datasource: IServerSideDatasource = {
getRows: params => {
const res = server.getData(params.request);
res.success ? params.success({ rowData: res.rows, rowCount: res.total }) : params.fail();
},
};
What you get for free: block cache (cacheBlockSize default 100, maxBlocksInCache LRU eviction), blockLoadDebounceMillis to suppress fetches during fast scrolls, and automatic cache purge + refetch when sort/filter change [15]. The Infinite Row Model is Community, but “aggregation and grouping are not available in infinite scrolling” [15] — so any grouped server table means SSRM, which is Enterprise [13]. AG Grid’s own guidance: “Server-Side Row Model is Infinite Row Model plus more. So if you are an AG Grid Enterprise customer, you should prefer Server-Side Row Model” [13].
⚠ TanStack Query friction. getRows is a plain callback, not a component, so you cannot call useQuery inside it. The working pattern is const qc = useQueryClient() in the component and qc.fetchQuery({ queryKey: [...request], queryFn }) inside getRows, memoising the datasource with useMemo and putting every request param in the key [17]. You are then running two caches (Query’s and the grid’s block cache) — decide which one is authoritative before you build.
MUI X: Data Source, and the plan cliff
GridDataSource.getRows(params) receives filterModel, sortModel, paginationModel, start/end, groupKeys, aggregationModel; the grid handles caching (GridDataSourceCacheDefault, 5-minute TTL, chunk-split for hit rate), errors via onDataSourceError() (GridGetRowsError vs GridUpdateRowError), loading overlays, and dataSourceKeepPreviousData to hold rows during a refetch [18]. dataSourceCache accepts any {get,set,clear} object, so a TanStack QueryClient can back it directly [18]. dataSourceRevalidateMs adds background polling [18].
The classic manual path also still exists on the MIT grid: paginationMode, sortingMode, filterMode all accept 'server', and dataSource is on the free DataGrid API surface [22]; v8 explicitly moved the Data Source into the Community plan [23].
Where the plan cliff bites:
| Capability | Plan |
|---|---|
paginationMode/sortingMode/filterMode="server", basic dataSource |
Community (MIT) [22][23] |
lazyLoading (viewport skeletons + infinite scroll) |
Pro [19] |
| Server-side aggregation, row grouping, tree data | Premium [20] |
⚠ Row-count trap: “If the value rowCount becomes undefined during loading, it will reset the page to zero” — memoise it across fetches [21].
Cursor vs offset: MUI’s lazy loading picks its strategy from the count. Known rowCount → viewport loading (skeleton rows, fetch when a skeleton enters the render window, 500 ms request throttle). rowCount = -1 or undefined → infinite loading (fetch at scroll-end, stop when the server returns nothing) [19]. That maps cleanly onto offset vs cursor backends: cursor APIs cannot cheaply produce a total, so hand the grid -1 plus paginationMeta.hasNextPage (or estimatedRowCount) [21]. In TanStack land, cursor pagination is useInfiniteQuery + getNextPageParam returning the server’s nextCursor [10].
The data layer: Query / SWR
TanStack Query (⭐ 50k) is the default pairing for the opt-out grids. v5 removed keepPreviousData and isPreviousData; you now write placeholderData: keepPreviousData (the imported identity helper) and read isPlaceholderData [9]. Its value for tables: “the data from the last successful fetch is available while new data is being requested, even though the query key has changed,” and swaps seamlessly on arrival [8] — this is what kills the page-flicker-to-empty. Gate the Next button on isPlaceholderData || !data.hasMore so users can’t over-page into placeholder data [8]. Infinite tables use useInfiniteQuery (+ maxPages to cap retained/refetched pages) with @tanstack/react-virtual; TanStack ships an official Table + Query + Virtual infinite example [7][10].
SWR does the same job with one option: useSWR(\/api/data?page=${page}`, fetcher, { keepPreviousData: true }) keeps prior data across key changes [[11]](https://swr.vercel.app/docs/pagination). It has no equivalent of Query's fetchQuery` imperative escape hatch story for grid datasources, so for AG Grid SSRM / MUI Data Source, Query is the better fit.
Debounce ≠ race safety
React’s own docs are blunt: “network responses may arrive in a different order than you sent them” — the useEffect + ignore-flag cleanup exists precisely for this, and the docs then tell you not to hand-roll it at all and to use TanStack Query or SWR [30]. Applied to tables:
- Debouncing a filter input only reduces how many requests fire. If a request outlives the debounce window, the stale response can still land last and overwrite the fresh one [30][31].
- Putting
{pagination, sorting, filters}into the query key structurally removes the race: each parameter combination is a distinct cache entry, so a late response writes into its own key, not the visible one [8]. - Debounce the state update that feeds the key, not the fetch, and always reset
pageIndexin the same transaction as the filter/sort change [5]. - The datasource grids sidestep most of this: AG Grid purges and refetches on sort/filter change [15] and MUI throttles viewport requests to 500 ms [19].
RSC / App Router reality
No grid renders in a Server Component. All four need 'use client', and once a file is marked, “all of its imports and the components it directly renders are included in the client bundle” [29]. AG Grid says it outright: ag-grid-react “depends on some browser-specific APIs (e.g. window/document) and can not be rendered server-side” [16].
The pattern that actually works in the App Router is URL-as-table-state:
- Server Component
page.tsxparsessearchParams(a Promise since Next 15) viacreateSearchParamsCacheand passes typed page/sort/filter down without prop-drilling [26]. - It fetches server-side and streams rows into a thin
'use client'table shell. - The client shell writes state back with nuqs ⭐ 10.7k
useQueryState;shallow: falseopts into “notifying the server (to re-render Server Components on the app router)”, withthrottleMsto rate-limit — the hook’s returned state updates instantly regardless, only the URL write and server round-trip are throttled (default 50 ms; ~340 ms on Safari) [27].
That throttleMs is your debounce for RSC round-trips. The canonical reference implementation is shadcn-table ⭐ 6.2k — TanStack Table + Next.js with “server-side pagination, sorting, and filtering”, Notion-style advanced filters and virtualized infinite scroll [28]. AG Grid and MUI X can live inside this too, but their state lives in the grid’s own model objects, so you must serialise sortModel/filterModel into the URL yourself — the URL-state ergonomics favour the headless option.
Verdict by shape of your server
| Your situation | Pick |
|---|---|
| Flat rows, offset or cursor paging, sort + filter, no grouping | TanStack Table + TanStack Query (manual* + placeholderData: keepPreviousData) [1][8] |
| Table state must live in the URL / RSC + App Router | TanStack Table + nuqs; grid-model libraries fight you here [26][28] |
| Server-side grouping, pivot, aggregation over millions of rows | AG Grid SSRM (Enterprise) [12][13] |
| Already on MUI, want server grouping/aggregation without writing the engine | MUI X Data Source, Pro for lazy load, Premium for aggregation [19][20] |
| Mantine design system, simple server paging | Mantine React Table — but it is a TanStack v8 wrapper with a stale release cadence (last tagged release Oct 2023) [25]; ⚠ no server-side story of its own |