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 read

const 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 serverAfter hydration
Root, header, ARIA rolesrenderedunchanged
Column captionsrenderedunchanged
Row windowemptymeasured and filled
Web Workernever constructedconstructed above the ~20,000-row threshold
Remote DataSourceload() called once, never awaitedloaded 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, document or a ResizeObserver outside 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.

Tagsengineeringnextjsssr