Make every client component safe to render on the server

Fixkamo-internal
Shipped
August 26, 2026 at 3:03 AM UTC
Author
kamo
Commit
e029a0f

Next server-renders client components, so `window`, `document`, `localStorage`, `sessionStorage` and `navigator` do not exist during the render that produces the HTML. Reading one in a component body throws ReferenceError on the server, and a throw there is a blank page rather than a degraded one. Four components did it. FloatingChatWindow read window.innerWidth/innerHeight in a useState initialiser and again in its drag constraints; ChatBox computed its maximized height from window.innerHeight; Lightbox and AddToContactsDialog portalled into document.body with no guard. All four are behind a caller that only mounts them after a user action, so the server never reaches them today — these guards are what keep that an implementation detail of the caller rather than a load-bearing assumption. FloatingChatWindow's position initialiser was also being recomputed on every render for a value useState discards after the first; it is lazy now. Adds scripts/check-ssr-safe.mjs, which walks every 'use client' file with the TypeScript compiler API and reports reads that happen during render. It runs in npm test. Getting the analysis right took three corrections, all worth recording because each one is a way to write this check wrongly: - Treating any FunctionDeclaration as "deferred" makes it report ZERO. A React component IS a function declaration, and its body runs during render. Only a lower-case one is a helper invoked later. - `window` and `document` are ordinary identifiers. A JSX attribute named `window=`, an object key `document:`, and a destructured prop `{ window: editing }` are labels, not reads — four real components use exactly those. - The idiomatic guard does not lexically wrap the read. `if (!mounted) return null` sits several statements above `createPortal(x, document.body)`, so walking up from the identifier never finds it. Four components were already safe this way, and a check that cannot see it would have had people "fixing" correct code. Verified against planted positive controls in both directions: an unguarded component is reported, a mounted-guarded one is not. tsc --noEmit clean over all four components and their transitive imports; all 10 guards pass. This clears the blocker on removing the MemberListProvider mount gate, which is what keeps the entire authenticated app out of the server-rendered HTML. Removing it is a separate change and still wants a route smoke pass, because it would be the first time these routes have ever been server-rendered.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing