Text Content Does Not Match: Fix Hydration Mismatch
Render deterministic HTML on both passes and defer client values to useEffect — mismatched dates, randoms, and window reads break hydration..
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓A Next.js app with server rendering
- ✓Basic React hooks knowledge
- ✓Familiarity with browser DevTools
- The server and client rendered different text, so React discarded the server HTML. Find the differing node in the error's expected-versus-actual quote.
- Date.now, Math.random, toLocaleString, and window reads during render guarantee a mismatch. Move them into useEffect and render a placeholder first.
- Pin locale and timeZone explicitly on every formatter. Server ICU data and browser ICU data rarely agree.
- Use suppressHydrationWarning only on single elements that must vary, like a live clock — never on whole subtrees.
Imagine two bakers following the same recipe in different kitchens. The server bakes at dawn with its own clock and local ingredients; the browser rebakes at noon with its clock and ingredients. If the recipe says decorate with today's date or a pinch of whatever's local, the cakes differ and the customer notices. Hydration is the customer comparing both cakes crumb by crumb. The fix is a recipe that bakes identically everywhere, then lets the browser add garnish after delivery.
You ship a Next.js page, open it, and the console warns: text content does not match server-rendered HTML. The page still works — React recovers — but first paint flickers, state resets inside the subtree, and you lose the speed you chose server rendering for.
The cause is almost always a render that isn't deterministic. The server ran it at one time with one locale and no window object; the client runs it a moment later with another locale and a full browser. Date.now, Math.random, toLocaleString, and window.innerWidth each guarantee the two outputs differ, and React's comparer is exact — one character off is a mismatch.
This surfaces hardest in clocks, greeting banners, formatted prices, and responsive layouts. They look innocent in development, where server and client share your machine's locale and clock, then break in production where they share nothing.
This guide covers the whole category. You'll learn what hydration compares, which expressions can never match, how to defer client values with useEffect, and the narrow cases where suppressHydrationWarning is the honest answer.
What Hydration Compares and Why Text Must Match Exactly
Hydration is React's reconciliation of two renders: the HTML string the server produced and the virtual tree the client builds on load. React walks both node by node, and for text nodes it compares character for character. Any difference — a timestamp, a locale comma, an extra space — fails the comparison for that node.
When a mismatch hits, React doesn't patch the text. It discards the server-rendered subtree and client-renders fresh. The page still ends up correct, which fools teams into treating the warning as cosmetic. But the recovery costs the exact benefits server rendering promised: fast first paint flashes into a re-render, and any state inside the replaced subtree resets to initial values.
The error message is genuinely helpful. It prints the server HTML snippet and the client text it expected instead, so the first differing character points at the guilty expression. Learning to read that quote — rather than reaching for the suppression flag — resolves most reports in minutes.
The rule that follows is absolute: the client's first render must be byte-identical to the server's output. Everything dynamic — clocks, randoms, browser measurements — belongs after mount, never in the render path. The next sections show each offender and its deferral pattern.
Date.now, Math.random, and Locale Strings That Break Render
Date.now and new Date capture different moments on server and client by construction. The server renders at request time; the client hydrates milliseconds or seconds later. Even a one-second boundary crossing flips the text, and sub-second timestamps differ on literally every load. There is no clock value that matches across two renders.
Math.random is worse: its contract guarantees difference. Using it for ids, keys, or displayed values means the server and client can never agree. Keys deserve special mention because mismatched keys don't just warn — they scramble component identity, unmounting and remounting list items and destroying their state.
Locale formatting fails more subtly. toLocaleString and Intl.NumberFormat draw on ICU data that differs between Node versions and browsers. A price like 1,234.50 on your laptop renders as 1.234,50 in a German browser or with different grouping on a minimal-ICU server. Development hides this because server and client share your machine; production splits them across the world.
The shared trait is render-scope nondeterminism: any expression whose value depends on when, where, or in which runtime it runs. Audit renders for time, randomness, and locale — if the value can differ between two machines, it will differ between server and client.
Reading window or navigator During Render Hydrates Wrong
The server has no window, no navigator, no matchMedia, and no localStorage. Code that reads them during render either crashes the server build outright or — when guarded with typeof checks — renders the fallback on the server and the real value on the client. Both branches produce text, and the texts differ.
Responsive layouts are the classic victim. Rendering mobile markup when window is undefined and desktop markup when it's 1440px wide guarantees a mismatch on every desktop load. The same applies to theme detection via matchMedia, language via navigator.language, and auth state via localStorage tokens.
The typeof window guard feels like a fix but only converts a crash into a mismatch. The server takes the fallback branch, the client takes the real branch, and hydration compares fallback text against real text. You've traded an exception for a flicker — better uptime, same broken first paint.
The correct shape renders the fallback on both passes, then upgrades after mount. Default state holds the server-safe value, useEffect reads the browser API and calls setState, and the client re-renders with the real value moments later. First paint matches, enhancement follows, and no warning fires.
Rendering Client-Only Values Safely With useEffect
The useEffect deferral pattern has three steps. First, pick a deterministic placeholder: a skeleton, a dash string, or the most likely server-safe value. Both server and client render it, so hydration matches trivially. Second, compute the real client value inside useEffect, which runs only on the client after mount. Third, store it in state so the component re-renders with live data.
Placeholders deserve care because users see them. A clock showing --:-- for one frame is fine; a layout that jumps when the real value arrives is not. Reserve space with fixed widths or skeleton blocks so the post-mount update swaps content without moving pixels. The goal is identical text at hydration and stable layout after enhancement.
This pattern also handles formatting correctly. Pass explicit locale and timeZone options inside the effect — the computation runs only in the browser, so only one ICU implementation matters. Server output stays a static placeholder with no locale dependency at all.
For data that arrives asynchronously, extend the same shape: placeholder, then fetch in the effect, then setState. The placeholder can even be the server-fetched value with client-only decorations applied after mount. Deterministic base plus client garnish covers nearly every real design.
When suppressHydrationWarning Is the Correct Fix
suppressHydrationWarning exists for text that legitimately differs between server and client — a live timestamp, a timezone abbreviation, a value that updates every second by design. Adding it to that single element tells React to skip the text comparison there while hydrating everything else normally.
The keyword is single. The flag suppresses warnings only for the text directly inside its element, so scoping it to one span keeps every surrounding node fully checked. That's the honest use: you've audited the tree, everything deterministic matches, and one live value can't match by definition.
Misuse is covering a parent div to silence several mismatches at once. The warning stops but the underlying divergence remains — React still client-renders the differing parts, layout still shifts, and future real bugs hide behind the same flag. Reviewers should treat a broad flag like a disabled test: suspicious until proven necessary.
Prefer determinism first even for live values. A clock can render a placeholder and fill in after mount with no flag at all. Reserve suppression for cases where the placeholder itself harms the design — SEO-critical timestamps, for example. When you do use it, leave a comment naming why that node must vary.
Finding the Mismatched Node From the Hydration Error
Start from the error quote, not from guesses. Next.js prints the server text and the client text; the first divergence names the expression. Search the tree for that string's source — a date pipe, a price formatter, a conditional on window — and you've usually found the line within minutes.
Next, reproduce deterministically. Reload twice: values that change per reload implicate time or random; values stable per machine but different across machines implicate locale or browser APIs. Toggle timezones between runs to confirm locale suspects. Each pattern maps to its section above with a known fix.
Then verify structurally. View page source for the raw server HTML and compare it against the client's first render captured in a mount effect. After the fix, both must agree character for character while the post-mount enhancement still delivers live values. That two-step check — match at hydration, live after mount — is the acceptance test.
Finally, lock it with tests. Render the component to a string in Node and in a browser-like harness with differing locales and clocks, and assert identical output. Add a lint rule against Date.now, Math.random, and bare window reads in render scope. Hydration bugs are famously recurring; automation keeps them fixed.
Sale-Week Price Flicker That Traced to One Unpinned Locale
- Never rely on runtime defaults that differ between Node and browsers. Pin locale, timezone, and currency everywhere user-visible text is formatted.
- Dev-prod parity must include locale and timezone, not just code and data. A staging server in the same region as your laptop proves nothing.
- Recovering gracefully still costs you: React's client re-render forfeits the speed hydration was bought for. Deterministic first paint is the real fix.
| File | Command / Code | Purpose |
|---|---|---|
| clock-bad.js | function ClockBad() { | Date.now, Math.random, and Locale Strings That Break Render |
| width-guard.js | function WidthBad() { | Reading window or navigator During Render Hydrates Wrong |
| local-time.js | export function LocalTime({ epoch }) { | Rendering Client-Only Values Safely With useEffect |
| suppress-legit.js | export function LiveClock() { | When suppressHydrationWarning Is the Correct Fix |
Key takeaways
Common mistakes to avoid
5 patternsCalling Date.now or new Date() during render
Using Math.random for keys or ids in rendered output
Reading window or navigator during render
Formatting with toLocaleString and no explicit locale
Slapping suppressHydrationWarning on a whole subtree
Interview Questions on This Topic
What does text content does not match mean during hydration?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's React.js. Mark it forged?
5 min read · try the examples if you haven't