Skip to content
Code2026-09-073 min read

How to Fix 'Hydration failed / Text content does not match' in Next.js

Symptom: a wall of hydration errors on startup

A Next.js project logs this to the console:

Hydration failed because the server rendered HTML didn't match the client.
Text content does not match server-rendered HTML.

In one sentence: the server pre-rendered HTML, then React re-computed the tree during hydration on the client and found a mismatch — so it discards the server output and re-renders from scratch, throwing away both SEO and first-paint performance, the entire point of SSR.

Why renders diverge: the three usual suspects

| Source | Typical code | Why it diverges | |---|---|---| | Time / randomness | new Date().toLocaleTimeString(), Math.random(), Date.now() | Server render time ≠ client hydrate time — always different | | Browser-only APIs | window.innerWidth, localStorage.getItem(), navigator.userAgent | No window/localStorage on the server; reads resolve to fallbacks | | Browser extensions | Grammarly, translators injecting DOM attributes | Error appears only in your browser; colleagues can't reproduce — disable extensions first |

The third is the easiest to miss: if you only see this on one machine, the first step is an incognito window — if it disappears, an extension is the culprit.

Fixes, matched to each scenario

1. Time / random values: defer to the client

const [time, setTime] = useState<string | null>(null);
useEffect(() => {
  setTime(new Date().toLocaleTimeString());
}, []);
return <span>{time ?? '--:--:--'}</span>;

The server renders a stable placeholder; the real value fills in after mount. First-paint HTML stays deterministic and the client never conflicts.

2. localStorage / window: read in useEffect, store in state

const [theme, setTheme] = useState('light');
useEffect(() => {
  setTheme(localStorage.getItem('theme') ?? 'light');
}, []);

Don't write typeof window !== 'undefined' && localStorage... in the render path — the server renders false, the client renders true: still a mismatch. Stateful is the only correct pattern.

3. Deterministic display values: suppressHydrationWarning (with care)

<time dateTime={date} suppressHydrationWarning>{new Date(date).toLocaleTimeString()}</time>

Suppresses text/attribute mismatches on that element only (not the subtree). Appropriate for timestamps where "values differ but both are correct". Not appropriate for structural divergence (different element counts/types) — that hides real bugs.

4. Whole-block deferral: the ClientOnly pattern

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null; // server and first client render agree: render nothing
return <HeavyBrowserWidget />;

The standard approach for browser-dependent widgets (charts, editors, maps).

Debugging: finding the offending line

React 18's error is long but doesn't name the element. To locate it:

  1. Compare view-source: (server HTML, Cmd+U) against the DevTools Elements DOM;
  2. Or paste both into the Text Diff — the diverging line lights up instantly;
  3. Typical divergences: extra attributes (extension injection), different text (time/random), missing elements (conditional branches differing per environment).

FAQ

Does this hurt SEO?

Yes. After a hydration failure React re-renders the whole tree and the client version overwrites the server HTML — if the client version is missing content (a JS error blanking the render), Google's final snapshot is empty too.

Why only in production?

Development mode skips strict hydration validation (warning only), and errors compound differently. Dev being clean proves nothing — check the production console after deploying.

Does suppressHydrationWarning work on the parent?

No. It covers that element's own attribute/text mismatch, not children. For subtree-wide divergence use the ClientOnly pattern.

The checklist

  1. Reproduce in an incognito window — gone = extension issue
  2. Cmd+U for SSR HTML vs DevTools Elements for client DOM, diff the two
  3. Grep the render path for Math.random|Date.now|localStorage|window.
  4. Time/random → useState+useEffect; browser API → ClientOnly; deterministic → suppressHydrationWarning
  5. Re-check the production console after fixing

Provided by ToolVault. Related tools: Text Diff, JSON Formatter, JSON to TypeScript. See the homepage for more developer tools.


Advertisement