What a data grid renders before it hydrates
When an install guide makes ssr false a required step, it is worth asking what the component touches on the server. Here is what actually has to happen before a grid can render there, and what changes after hydration.
KanuniLabs6 min readconst Grid = dynamic(() => import('grid-package'), { ssr: false }) is a
reasonable line when you choose it. Our own homepage uses it for its live
demos, so they load as a separate chunk instead of growing the page's first
bundle. It's a different thing when an
install guide makes it a required setup step, because then it means the
component cannot run on the server at all: the page ships a loading
placeholder, swaps in the real thing after JavaScript arrives, and every
crawler that does not execute your bundle sees an empty box where the grid
was supposed to be.
"The component holds a lot of interactive state" doesn't force that. What
forces it is usually something specific the component's code touches
outside of an effect — window, document, ResizeObserver, a measured
element size — none of which exist while Next is rendering your page to a
string on a server that has never opened a browser tab.
What actually breaks on the server
A grid does three kinds of work, and only one of them is a server problem.
Reading the data and the columns is plain JavaScript. Nothing about
iterating an array or reading a columns prop needs a browser.
Drawing the shape of the UI — header, toolbar, ARIA roles, column captions — is plain React. It renders to a string the same as any other component.
Measuring the viewport and deciding how many rows fit is not available
yet. The browser has not laid anything out. A grid that calls
ResizeObserver or reads element.clientHeight in the render path, instead
of inside useEffect, throws in Node — and ssr: false is the fastest way
to make that exception go away without finding out why it happened.
What our grid renders on each side
| On the server | After hydration | |
|---|---|---|
| Root, header, ARIA roles | rendered | unchanged |
| Column captions | rendered | unchanged |
| Row window | empty | measured and filled |
| Web Worker | never constructed | constructed above the ~20,000-row threshold |
Remote DataSource | load() called once, never awaited | loaded by the client |
The row window is empty because the number of rows depends on the container's height, and the server has no container to measure. It could guess — render, say, 20 rows because that is a common viewport height — but a guess is wrong for most screens, and the client would replace those rows the moment it measured the real container. What the server does send is the skeleton and the column headers, so the pre-hydration page is a real, readable grid shape, not a blank div.
One row of that table is worth reading twice. On a remote source, the
server render calls your load() once and doesn't wait for it; the markup
goes out without rows, and the client loads its own after hydration. With
an absolute URL, that is one extra request per page render whose answer is
thrown away. A relative fetch('/api/...') just fails on the server, and
the grid absorbs that failure without breaking the render.
This is not a claim we ask you to take on faith — it is covered by tests that render the component to a string in a DOM-free environment: one with the toolbar, filter row, header filter, group panel, pager and summary bar all switched on at once, and a separate one checking that a remote data source renders without the server waiting on it.
What this looks like in Next.js
The grid is a client component because it holds state and subscribes to events — that part genuinely is unavoidable, in this or any interactive component. What is avoidable is pushing that boundary up to the whole page.
'use client';
import { DataGrid } from '@kanunilabs/datagrid-react';
import '@kanunilabs/datagrid-react/styles.css';
export function SalesGrid({ rows }) {
return <DataGrid gridId="sales" dataSource={rows} rowKey="id" columns={columns} />;
}
A server component fetches the rows and passes them down as a prop; only the
leaf that renders <DataGrid> needs 'use client'. The stylesheet import
can live in that same file or in the root layout; import it once, in
whichever of the two you prefer.
The worker doesn't need a file to resolve
A Web Worker is another reason a component can end up opting out: bundlers resolve worker URLs at build time, and getting that resolution wrong on the server is its own class of error. We compile the worker into the package and start it from a Blob URL at runtime, so there is no worker file for Next — or Vite, or anything else — to resolve during a server render. Nothing to configure, because there is nothing to find.
Two situations still want the real file, and it ships in the package for
them: a Content-Security-Policy without worker-src … blob:, and wanting
the worker as its own cacheable file instead of inlined in your bundle.
Serve it from your own origin either way — a browser won't start a worker
script from another domain.
cp node_modules/@kanunilabs/datagrid-core/dist/datagrid.worker.js public/
<DataGrid worker={{ url: '/datagrid.worker.js' }} /* ... */ />
If the worker cannot start for any reason — a strict CSP nobody remembered to update, an environment where Blob URLs are unavailable — the grid does not go blank. It runs the same sort and filter pipeline on the main thread instead and logs a warning once, in development only. The user notices nothing except that a very large sort now costs main-thread time instead of running off it.
What to check if a grid forces ssr: false on you
- Does it touch
window,documentor aResizeObserveroutside an effect? That is the actual constraint, and it is narrower than "the whole component can't render server-side." - Does it need a worker file resolved by your bundler at build time? That is a second, independent reason to opt out, and it is solvable either by shipping the worker compiled-in or by giving the bundler an explicit, static import for it.
- What does the pre-hydration markup actually contain? An empty placeholder and a rendered skeleton with real headers are different pages to a crawler, even though a logged-in user sees the same final grid either way.
Try it with JavaScript disabled, or curl the page — the header row and
column captions should already be there. Our DataGrid
page server-renders a real grid, so
curl -s https://kanunilabs.com/datagrid | grep -o 'role="columnheader"' | wc -l
counts its column headers straight out of the HTML. If the captions are not there, the SSR support is something
you can't check in the response body.
The row window fills in after hydration by measuring the container — the same viewport math covered in the virtual scrolling post. Full setup for Next.js, Vite and other bundlers is on the SSR & bundlers page.