Skip to content
Code2026-09-076 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

SourceTypical codeWhy it diverges
Time / randomnessnew Date().toLocaleTimeString(), Math.random(), Date.now()Server render time ≠ client hydrate time — always different
Browser-only APIswindow.innerWidth, localStorage.getItem(), navigator.userAgentNo window/localStorage on the server; reads resolve to fallbacks
Browser extensionsGrammarly, translators injecting DOM attributesError 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 three most common mismatch sources in 2024-era stacks

Date formatting (the classic — server UTC renders a different day than client in another timezone; fix: format deterministically, same locale and timezone both sides, or render after mount), browser-extension injection (Grammarly/Password managers inject attributes and spans into the DOM before hydration completes — the error points at your component, the culprit is the extension; fix: suppressHydrationWarning on the affected element is the pragmatic, contained answer), and environment-dependent values (Math.random, Date.now, window checks in render — any nondeterminism between server and client passes). The diagnostic order: reproduce in incognito with extensions off (isolates cause two), hard-code a timestamp temporarily (isolates cause one), and grep the component tree for nondeterministic calls in render paths (cause three — the only one that is your bug).

SSR + i18n: the locale minefield

Locale-aware rendering amplifies every source: server locale (Node's ICU setup) vs browser locale differ; date/number/currency formatting silently diverges; and locale negotiation (Accept-Language parsing) can pick differently server-side vs client-side. The architectural fixes: pass the resolved locale through props/context from a single negotiation point (never let client and server negotiate independently), format dates with explicit locale + timezone arguments (not ambient defaults), and for time-relative strings ("2 hours ago"), render the absolute time server-side and upgrade to relative client-side after mount — the visible swap is honest, the hydration mismatch is not.

The error surface: reading the React message

React's hydration error output improved but still buries the lead: the tree hint (component path) is where to look; the text diff (expected vs received) is what changed; the stack trace is usually the framework, not your code. When the diff shows attributes (class vs className, style strings), suspect extensions or CSS-in-JS; when it shows text nodes, suspect formatting/nondeterminism; when it shows whole subtrees appearing/disappearing, suspect client-only conditionals (window checks). Suppression (suppressHydrationWarning) is legitimate for known-divergent leaf content (timestamps, extension-targeted inputs) — it is not a fix for structural mismatches, where the suppressed warning hides a real client/server behavioral fork.

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

Diffing SSR vs client without guessing

Save “View Source” (SSR) and a copy of the hydrated DOM (Elements → copy outerHTML of the mismatch root). Paste both into a text diff tool and ignore attribute noise first — focus on text nodes and whether a whole subtree appeared only on one side. If the only delta is whitespace inside a <pre> or formatted number, fix formatting to be deterministic; if a branch is missing on the server, you still have a typeof window gate to remove or defer.

Implementation notes

Debugging hydration mismatches often means lining up two long texts line by line, so the site's Text Diff persists both inputs locally — diff work involves the longest pastes and the costliest re-pasting, and a page refresh shouldn't clear the battlefield. Separately, when checking API shapes via JSON-to-types, heterogeneous arrays produce a union element type instead of silently dropping everything after the first element.


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