Data Tables in Next.js: Virtualizing 10,000 Rows Without Losing the Server
Virtualization forces the table into a Client Component and hands back the sorting, filtering and accessibility the server did for free. The three options, and the hybrid worth using.
Virtualize a data table when the user genuinely needs one continuous scroll over 10,000 rows. Everything else — reports, listings, admin CRUD — is better served by server-side pagination, because virtualization forces the whole table into a Client Component and hands you back the sorting, filtering and accessibility work the server was doing for free.
We build admin templates, so we have shipped both. This post is about the tradeoff nobody puts in writing: what you actually give up when you swap pagination for a virtualizer, and how to keep most of it.
The three options, honestly
| Server pagination | Client virtualization | Server-paginated + virtualized window | |
|---|---|---|---|
| Rows in the DOM | One page (~25–50) | ~15–30 | ~15–30 |
| Rows in the JS heap | One page | All 10,000 | One large page (~500–2,000) |
| Where sorting happens | Database | Browser | Database |
| Where filtering happens | Database | Browser | Database |
| Initial payload | Small, fixed | The entire dataset | Bounded |
| Component type | Server Component | Client Component | Server shell + client body |
| Deep-linkable state | Yes, via URL | No, unless you build it | Yes, via URL |
| Ctrl+F finds a row | Only on the current page | No | No |
| Scroll feel | Paged | Continuous | Continuous within a page |
| Right for | Most admin tables | Log viewers, pickers, spreadsheets | Large but scrollable datasets |
The middle column is the one people reach for first because the demos are impressive. The demos are also all client-side by construction — the library is showing you its own scroll performance, not an architecture.
Start by asking whether you need it at all
Virtualization solves exactly one problem: too many DOM nodes. It does not make your query faster, your payload smaller, or your table more usable. If your table is slow and you have not measured which of those three it is, you will virtualize and find it is still slow.
The cheap diagnostic, in order:
- Is the query slow? Time it server-side. A missing index on the sort column is the single most common cause and virtualization cannot touch it.
- Is the payload large? 10,000 rows of eight columns is roughly 2–4 MB of JSON before compression. That is a transfer and parse cost you pay before a single row renders, and a virtualizer does not reduce it — it still needs the whole array to know the scroll height.
- Is rendering slow? Only now is virtualization the answer. 10,000
<tr>elements with a handful of children each is 60,000–100,000 DOM nodes; that is where layout and memory actually fall over.
Points 1 and 2 are why "just virtualize it" so often disappoints. The visible rows render instantly and the page still takes four seconds to become useful, because the bottleneck was upstream of rendering the entire time.
The default: paginate on the server, keep state in the URL
For the large majority of admin tables, this is the right answer and it is barely any code. The page stays a Server Component, the query runs in the database, and the table's state lives in searchParams — the same rule we make for product filtering, for the same reasons: a filtered, sorted, paged view should be shareable and bookmarkable.
// app/(admin)/orders/page.tsx — a Server Component
type SearchParams = Promise<{
page?: string;
sort?: string;
dir?: "asc" | "desc";
q?: string;
}>;
const PAGE_SIZE = 50;
const SORTABLE = ["created_at", "total", "status"] as const;
export default async function OrdersPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { page = "1", sort = "created_at", dir = "desc", q } = await searchParams;
// Never interpolate a user-supplied column name into SQL. Allowlist it.
const column = SORTABLE.includes(sort as (typeof SORTABLE)[number])
? sort
: "created_at";
const direction = dir === "asc" ? "asc" : "desc";
const pageIndex = Math.max(0, Number(page) - 1 || 0);
const { rows, total } = await listOrders({
offset: pageIndex * PAGE_SIZE,
limit: PAGE_SIZE,
column,
direction,
search: q,
});
return (
<OrdersTable
rows={rows}
total={total}
pageIndex={pageIndex}
pageSize={PAGE_SIZE}
/>
);
}
Two details that matter more than they look:
- The allowlist on
column. A sort parameter is a user-controlled string that ends up in a query. An allowlist is the difference between a sort control and an injection point, and it costs one line. OrdersTabledoes not need"use client". Rendering rows and links is server work. Only a leaf that owns state — a row-selection checkbox, a column menu — becomes a Client Component, and it receives its data as props.
At page sizes up to a few hundred rows this outperforms virtualization on every metric a user can perceive, because there is nothing to hydrate.
When you do need continuous scroll
Log viewers, entity pickers, spreadsheet-like grids, anything where paging destroys the task. Here the browser owns the scroll, so the table becomes a Client Component and you pick up the consequences deliberately.
@tanstack/react-virtual is the current default and it is unopinionated enough to use with your own markup:
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
const ROW_HEIGHT = 44;
export function VirtualOrders({ rows }: { rows: Order[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
});
const items = virtualizer.getVirtualItems();
return (
<div ref={scrollRef} className="h-[600px] overflow-auto">
<table
className="grid w-full"
aria-rowcount={rows.length}
aria-label="Orders"
>
<thead className="sticky top-0 z-10 grid">
<tr className="grid grid-cols-[1fr_8rem_8rem]">
<th scope="col">Customer</th>
<th scope="col">Total</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody
className="relative grid"
style={{ height: virtualizer.getTotalSize() }}
>
{items.map((item) => {
const row = rows[item.index];
return (
<tr
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
aria-rowindex={item.index + 1}
className="absolute grid w-full grid-cols-[1fr_8rem_8rem]"
style={{ transform: `translateY(${item.start}px)` }}
>
<td>{row.customer}</td>
<td>{row.total}</td>
<td>{row.status}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
The display: grid on a <table> is not a typo
This is the detail that catches everyone. Absolutely positioning a <tr> inside a native table does nothing useful — the table layout algorithm ignores position on row and cell boxes, so your rows stack at the top in a heap.
The fix is to opt every table element out of table layout (display: grid on the table, head, body and rows) and size the columns yourself with a grid template. The elements stay <table>, <tr>, <th> and <td>, so the semantics and the screen-reader table model survive; only the visual layout algorithm changes. What you lose is automatic column sizing — every column width is now a number you chose, and they must match between <thead> and <tbody>.
If that tradeoff sounds bad, it is a good sign you should be paginating.
The accessibility bill
A virtualized table is a table where most rows do not exist. Three consequences, and only the first is usually handled:
- Row counts lie. Assistive technology announces "row 12 of 30" because 30 is what is in the DOM.
aria-rowcounton the table andaria-rowindexon each row restore the real numbers — both are in the code above, and both are routinely omitted. - Ctrl+F stops working. The browser cannot find text that was never rendered. If find-in-page is part of how people use the table, you owe them an in-table search field, and it must filter the data rather than the DOM.
- Focus can be unmounted mid-interaction. Focus a row action, scroll it out of range, and the focused element is removed — focus falls back to
<body>and keyboard users lose their place. Keep a focused index in state and render that row unconditionally, outside the virtual window if necessary.
Our own Lighthouse work is a standing reminder that this class of bug survives code review: the audit that took our pages to accessibility 100 found a blog index with no <h1>, a docs page skipping h1 → h3, and a tag chip missing AA contrast by 0.01. None of those were caught by reading the code. Test the table with a keyboard and a screen reader, not with your eyes.
The hybrid, which is what most large tables actually want
Server-paginate at a large page size, virtualize within the page. The user gets continuous scroll over a window big enough that they rarely reach its edge; sorting and filtering still run in the database against the full dataset, so they remain correct.
// Sorting and filtering stay in the URL and run server-side; only the
// returned window is virtualized on the client.
const { rows } = await listOrders({
offset: pageIndex * 1000,
limit: 1000,
column,
direction,
search: q,
});
return <VirtualOrders rows={rows} />;
The rule that makes this work: sorting and filtering must operate on the dataset, never on the window. Client-side sorting of a 1,000-row window inside a 10,000-row table produces a table that is wrong in a way users do not notice until it matters — the "highest value order" is the highest in the window. If the control is on the server, its result is always the real one.
This is the shape we reach for in admin templates with genuinely large tables, and it composes with everything else: the page stays a Server Component, the URL stays the source of truth, and the client bundle only carries the virtualizer.
Mistakes and how they show up
| Mistake | What you see | Fix |
|---|---|---|
| Virtualizing before profiling | Rows appear instantly, page still slow | Time the query and the payload first |
Absolutely positioned <tr> in a native table | All rows stack at the top | display: grid on table, thead, tbody, tr |
| Column widths differ between head and body | Headers drift out of alignment on scroll | One shared grid-template-columns value |
No aria-rowcount / aria-rowindex | Screen reader announces "row 8 of 24" | Add both; they are two attributes |
| Client-side sort over a server page | Wrong "top" rows, no error | Sort in the query, key the URL to it |
| Unallowlisted sort column | SQL injection via ?sort= | Allowlist the column names |
estimateSize far from reality | Scrollbar jumps, position drifts | Measure with measureElement, or fix row height |
overscan: 0 | Blank rows during fast scroll | 5–10 rows of overscan |
| Fetching all 10,000 rows to a Client Component | 2–4 MB payload, slow hydration | Paginate the fetch, virtualize the window |
Frequently asked questions
At what row count should I start virtualizing? Not at a row count — at a behaviour. If the table is paginated and nobody complains, row count is irrelevant. If users need to scan continuously and pagination is breaking the task, virtualize regardless of whether that is 500 rows or 50,000. The DOM cost becomes measurable somewhere around 1,000–2,000 rendered rows on a mid-range laptop, which is a useful ceiling for "how big can a page be," not a trigger.
Can I use a virtualized table in a Server Component? No. Virtualization needs scroll position, element measurement and state, all of which are browser-only. The correct pattern is a Server Component that fetches and a Client Component that renders — the shell, the header, the filters and the pagination controls can all stay on the server, and only the scrolling body crosses the boundary.
Does virtualization hurt SEO? On an admin table, no — it is behind auth and should not be indexed at all. On a public page it absolutely does: crawlers see the ~15 rendered rows and nothing else. If the content needs to be indexed, paginate it with real linked URLs so each page is crawlable. This is the same reasoning behind which filtered URLs we let Google crawl on a storefront.
TanStack Table or a full data-grid component? TanStack Table is headless — it gives you sorting, filtering, grouping and column models with no markup, so it composes with the virtualizer and your own design system. A full grid (AG Grid, MUI DataGrid) brings its own markup, styling and a large bundle, and is worth it when you need spreadsheet features — cell editing, pinning, Excel export — rather than a table. For an admin built on Tailwind, headless plus your own markup keeps the design consistent and the bundle small.
How do I keep row selection across pages?
Store selected IDs, not row indices, and keep them outside the table — a Set in state for a session-scoped selection, or the URL if it should survive a reload. Indices are meaningless the moment the sort changes, and this is a common source of "it deleted the wrong records" bugs.
Start from an admin that already has the tables
Table architecture is the part of an admin build that takes longest to get right, and it is the part that is already done in a finished template.
ASoc Scholar Admin is the largest of the set — 13 dashboards and 210+ pages covering every app module, which is the scale at which these decisions stop being theoretical. ASoc Clover Admin is CRM-shaped, with sales, finance and team dashboards plus email and chat modules, so its tables are the transactional kind that want server pagination. ASoc Estate Admin manages real-estate listings and agents across three dashboards — long, filterable listing tables of exactly the sort discussed here.
All three ship React editions; browse the full set of React admin templates, or the Next.js admin templates if you want the App Router version of this architecture. If you are still choosing between the two runtimes, Next.js vs React + Vite for admin dashboards runs that decision, and the admin dashboard build guide covers the layout and server/client split this post assumes.
