Hydration Failed Because the Initial UI Does Not Match: Every Cause, Ranked
A complete diagnostic guide to React hydration mismatches: the eight real causes ranked by how often they actually happen, with the fix for each.
The short answer
React produced different HTML on the server than on the client. About ninety percent of the time it is one of three things:
- A date or number formatted with the visitor's locale
- A value read from
window,localStorage, orMath.random()during render - Invalid HTML nesting, such as a
divinside ap
Find the component by opening the browser console and reading the diff React prints under the error. It names the mismatched text.
The fix is almost never suppressHydrationWarning.
Tested on React 19.1 and Next.js 15.4 with the App Router. Message wording differs slightly on React 18.
What the error actually means
Server side rendering produces an HTML string. The browser paints it. Then React boots on the client, renders your component tree again, and walks the existing DOM attaching event listeners to the nodes it expects to find.
That second render is the important part. React does not read the DOM to work out what your app looks like. It re runs your components and assumes the output will match what the server produced. Hydration is a verification step, not a rendering step.
When the two disagree, React has a problem: the DOM in front of it does not correspond to the tree it just computed. Since React 18 it recovers by throwing away the server HTML for that subtree and doing a full client render. So the page usually still works, which is why this error gets ignored.
It should not be ignored:
- The recovery render is expensive and normally lands in your Interaction to Next Paint budget
- Content visibly flashes as the server version is replaced
- Where recovery is not clean, you get event handlers bound to the wrong nodes, so clicking one row deletes another
Step one: find the component
The stack trace is nearly useless. The diff is not. React 19 prints something like:
Hydration failed because the server rendered HTML didn't match the client.
<ProductCard>
<div className="price">
+ $1,299.00
- $1.299,00
+ is the client, - is the server. Here the digits are identical and the separators are swapped, which is a locale problem.
If you only get a generic message with no diff, you are looking at a production build. Reproduce with next dev, because the development build ships the comparison logic that generates the diff and production strips it.
For a stubborn one, binary search it. Comment out half the page, reload, repeat. Crude, but five reloads narrows a large route to a single component and it is often faster than reasoning about it.
The causes, ranked
1. Dates and times formatted during render
By far the most common. Roughly half the mismatches I have chased come down to this.
// broken
<span>{new Date(post.createdAt).toLocaleString()}</span>
toLocaleString() with no arguments uses the runtime's locale and timezone. Your server is UTC and en-US. Your visitor in Surat is Asia/Kolkata and en-GB. The server writes 3/2/2026, 10:05:00 AM, the browser writes 02/03/2026, 15:35:00. Mismatch.
The same applies to toLocaleDateString, toLocaleTimeString, Intl.DateTimeFormat without an explicit locale, and every date library that defaults to system settings, including date-fns format, Day.js, and Luxon's toLocaleString.
Pin both locale and timezone:
const fmt = new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
});
<span>{fmt.format(new Date(post.createdAt))}</span>
If you genuinely want the visitor's local time, that is client only information and has to render after mount:
const [local, setLocal] = useState(null);
useEffect(() => {
setLocal(new Date(post.createdAt).toLocaleString());
}, [post.createdAt]);
return (
<span suppressHydrationWarning>
{local ?? fmt.format(new Date(post.createdAt))}
</span>
);
Server and first client render both produce the UTC version, so hydration matches, then the effect swaps in local time. This is one of the few places suppressHydrationWarning is legitimate, and note it is scoped to a single leaf node.
Relative timestamps like "3 minutes ago" are the same bug wearing a disguise, and worse. The server rendered at 10:05:00 and the client hydrated at 10:05:02. Two seconds is enough to turn "just now" into "2 seconds ago". Always compute these on the client.
2. Number and currency formatting
<span>{price.toLocaleString()}</span> // 1,299 vs 1.299
Identical mechanism. Pass the locale explicitly:
new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(price)
Watch for this inside chart libraries and table components that format axis labels for you.
3. Reading browser only state during render
// broken
const isMobile = window.innerWidth < 768;
const theme = localStorage.getItem("theme") ?? "light";
On the server there is no window. Either it throws, or worse, a defensive guard quietly produces a different value:
const theme = typeof window !== "undefined"
? localStorage.getItem("theme")
: "light"; // server always light, client may be dark
That "fix" is the actual cause of an enormous number of hydration errors. It removes the crash and creates a mismatch.
Render the server safe value first, correct after mount:
const [theme, setTheme] = useState("light"); // matches server
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored) setTheme(stored);
}, []);
For theming specifically, the flash of the wrong colour is a real problem, and the standard answer is a small blocking inline script in head that sets a class on the html element before paint. html sits outside React's hydration root, so this causes no mismatch. That is what next-themes does internally.
4. Invalid HTML nesting
This one confuses people because the diff often looks nonsensical.
<p>
<div>Hello</div> {/* invalid */}
</p>
The browser's HTML parser enforces content models. A div cannot live inside a p, so the parser closes the paragraph early and reparents the div. The DOM shape now genuinely differs from what React rendered, through no fault of React's.
Common offenders:
divinsideppinsidep, easy to hit when a markdown renderer wraps children that are already paragraphsainsideaforminsideformtrortdnot inside atbody- Whitespace or text nodes directly inside a
table
React 19 detects many of these and prints a clearer message naming both tags. If you see one, fix the markup. This is not a hydration bug, it is invalid HTML that happens to surface here.
5. Math.random, Date.now, and crypto.randomUUID in render
<div key={Math.random()} id={`widget-${Date.now()}`}>
Different value on every render, by definition. Usually shows up in generated list keys or auto generated DOM ids.
For ids, use React's useId(), which exists precisely for this and produces a stable value across server and client. For keys, derive something from the data.
6. Browser extensions mutating the DOM
The maddening one, because it is not your bug. Grammarly, LastPass, Dark Reader, and various translation extensions inject attributes before React hydrates.
The tell is a diff showing an attribute you have never heard of:
- <div data-gramm="false" data-lt-installed="true">
+ <div>
Or a report that only ever comes from one colleague and never reproduces for you.
Confirm in an incognito window with extensions disabled. If that is the cause it is generally not worth defending against, with the exception of body and html, where extensions cluster and where suppressHydrationWarning is a reasonable blanket.
7. Conditional rendering on a value that resolves differently
{user && <Dashboard />}
If user comes from a client side auth store reading a cookie or an in memory token, the server renders nothing and the client renders a dashboard. That is a whole subtree mismatch.
Read auth state on the server, where cookies are available, and pass it down so both renders start from the same input. Where the value genuinely is not available server side, gate on a mounted flag and render a skeleton for the first pass.
8. Whitespace and the text node trap
Subtle and hard to spot:
<span>
{firstName} {lastName}
</span>
JSX whitespace handling around expressions and newlines can produce a different sequence of text nodes than the server serialised. Rare in modern React, but if you have a diff that looks like it is about nothing at all, with identical visible text and still a mismatch, try collapsing it to a single template literal.
Why suppressHydrationWarning is usually wrong
It does not fix the mismatch. It silences the warning for one element and its immediate text content. The DOM is still being patched, the flash still happens, and now you cannot see it. It also does not cascade to children, so people apply it and are confused when the warning persists from a nested node.
Legitimate uses: a timestamp you deliberately localise after mount, and body or html where extensions interfere. Everything else, find the cause.
Prevention
Lint for it. A no-restricted-globals rule for window and document in files that can render on the server catches cause three.
Write a custom rule banning zero argument toLocaleString and toLocaleDateString. This single rule would have prevented most of the instances I have personally debugged.
Fail CI on console errors. Run a production build, load your key routes in headless Chrome, fail the build on any console error. Hydration errors are console errors, so this catches them before users do.
Set one CI machine to a non UTC timezone and a non US locale. If your entire dev and CI fleet runs UTC and en-US, locale mismatches are invisible until a real user hits them. Setting TZ=Asia/Kolkata on one runner is close to free and surfaces an entire bug class.