Mingqi Hou

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:

Goals:

  1. Detect all risky patterns in the repo
  2. Fix with repeatable patterns
  3. 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:

RuleTargets
no-browser-api-in-renderwindow / document / localStorage outside effects
no-dynamic-value-in-renderDate.now(), Math.random() in JSX
no-direct-client-branchif (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:

  1. renderToString in Node
  2. Hydrate in JSDOM with the client bundle
  3. Diff root HTML (ignore pure class noise)
  4. 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

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.