Governing React SSR Hydration Mismatches in Next.js Apps
How I detect, fix, and prevent “server HTML didn't match the client”—ESLint rules, CI diff checks, and standard repair patterns.
Hydration errors in Next.js and other SSR React apps are expensive: React throws away the server tree, re-renders on the client, and users see flicker or layout shift. I treat them as engineering governance, not one-off bug hunts.
What we are solving
Typical error:
Hydration failed because the server rendered HTML didn’t match the client.
Common causes:
typeof window !== 'undefined'branches in renderDate.now(),Math.random(), locale formatting without a server snapshotwindow/document/localStorageduring render- Props/snapshot drift between server and client
- Invalid HTML the browser fixes differently than React expects
Goals:
- Detect all risky patterns in the repo
- Fix with repeatable patterns
- Prevent new violations in CI and review
Closed loop: detect → fix → prevent
Detect (ESLint + optional HTML diff in CI)
→ Fix (standard patterns per violation class)
→ Prevent (lint in PR, docs, templates)
Static detection: custom ESLint plugin
Community rules alone rarely cover hydration-specific render paths. I use a project plugin (conceptually eslint-plugin-react-hydration-ssr) with rules such as:
| Rule | Targets |
|---|---|
no-browser-api-in-render | window / document / localStorage outside effects |
no-dynamic-value-in-render | Date.now(), Math.random() in JSX |
no-direct-client-branch | if (typeof window !== 'undefined') choosing different trees |
Example config:
module.exports = {
plugins: ["@internal/react-hydration-ssr"],
rules: {
"@internal/react-hydration-ssr/no-browser-api-in-render": "error",
"@internal/react-hydration-ssr/no-dynamic-value-in-render": "error",
"@internal/react-hydration-ssr/no-direct-client-branch": "error",
},
};
Dynamic check: SSR vs client HTML diff (CI)
Lint misses data-driven and third-party edge cases. For critical routes, CI can:
renderToStringin Node- Hydrate in JSDOM with the client bundle
- Diff root HTML (ignore pure class noise)
- Fail the pipeline and attach a diff artifact
That turns “works on my machine” into a build signal.
Standard fixes
Browser-only APIs — move to useEffect / events; default SSR-safe state:
function WidthBadge() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <div>{width}px</div>;
}
Non-deterministic values — pass a server snapshot via props, or render placeholders until client mount:
// Server passes serializedNow; client hydrates the same string first
function Timestamp({ iso }: { iso: string }) {
return <time dateTime={iso}>{iso}</time>;
}
Client-only branches — prefer dynamic(..., { ssr: false }), dedicated client components, or suppressHydrationWarning only where truly intentional (document why).
Prevention
- PR lint gate on the hydration plugin
- Starter templates without
windowin render - Runbook linked from Next.js error overlay messages
- Pair with RUM / Core Web Vitals monitoring so regressions show in production, not only in error trackers
Why this matters on client work
Upwork “build website” gigs often mean marketing sites or dashboards on Next.js. Showing you can ship SSR and keep hydration clean signals senior React—not just page assembly.
For AI-heavy delivery on the same stack, see Cursor workflow and legacy refactors.