Atlas survey

Server-driven data: which React table actually does the work for you

AG Grid's Server-Side Row Model and MUI X's Data Source are real server-driven engines; TanStack Table only gives you manual* opt-outs and hands you the state machine.

31 sources ~8 min read #1 react · data-grid · tanstack-table · ag-grid · mui-x · server-side · tanstack-query · nextjs

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 / manualFiltering and the table stops doing the work. It does not start doing it on the server — it just believes whatever data array 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:

  1. Page reset is yours. autoResetPageIndex is disabled automatically when manualPagination is 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]. Wrap setFilters/setSorting so they also setPagination(p => ({...p, pageIndex: 0})).
  2. Debounce is yours. Nothing in the table batches keystrokes; naively you “perform a request with every keystroke” [31].
  3. Grouping is effectively unsupported. manualGrouping exists, 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();
  },
};

[14]

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 rowCountviewport loading (skeleton rows, fetch when a skeleton enters the render window, 500 ms request throttle). rowCount = -1 or undefinedinfinite 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 pageIndex in 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:

  1. Server Component page.tsx parses searchParams (a Promise since Next 15) via createSearchParamsCache and passes typed page/sort/filter down without prop-drilling [26].
  2. It fetches server-side and streams rows into a thin 'use client' table shell.
  3. The client shell writes state back with nuqs ⭐ 10.7k useQueryState; shallow: false opts into “notifying the server (to re-render Server Components on the app router)”, with throttleMs to 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

Citations · 31 sources

Click the Citations tab to load…