React Hydration Error (tested)

A hydration error happens when the HTML React renders on the server does not match what React renders on the client during the first render. React warns in the console and, outside a Suspense boundary, throws the whole root away and re-renders it from scratch on the client. We built a minimal server-rendered React app (no Next.js, same underlying APIs) and reproduced the two most common causes in headless Chromium, then verified the standard fix.

1. What a hydration mismatch is

Server-side rendering runs your component once on the server to produce HTML, then the browser loads your JavaScript and runs the same component again on the client. React's hydration step assumes both renders produce the same output, so it can attach event handlers to the existing DOM instead of rebuilding it. When the two renders disagree, React logs a mismatch warning. If the mismatch is bigger than text content React can patch in place, it discards the server-rendered subtree and re-renders that part on the client, which is slower and can cause a visible flash.

This is a React mechanism, not a Next.js-only one. Next.js's App Router and Pages Router both sit on top of react-dom/server and react-dom/client, the same APIs we tested directly.

2. Our test setup

We wrote a plain Express server that calls renderToString() for three small components, and a client <script> that calls ReactDOM.hydrateRoot() on the same markup, with no build step (React and ReactDOM loaded as UMD globals). We opened each page in headless Chromium via Playwright and captured every console.error/console.warn and uncaught page error, with React's %s placeholders substituted in so the text matches what you'd see in DevTools.

3. Scenario 1: non-deterministic output (Math.random())

A component that calls Math.random() (the same applies to new Date().toString(), Date.now(), or anything else that differs per call) directly in its render body:

function Scenario1() {
  return <div>Random: {Math.random()}</div>;
}

The server computes one random number, the client computes a different one on hydration. Chromium's console showed:

Warning: Text content did not match. Server: "Random: 0.7943628232528256" Client: "Random: 0.8602137549067519"
    at div
    at Scenario1
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.

And as an uncaught page error:

Text content does not match server-rendered HTML.
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.

The final DOM showed the client's value (Random: 0.43356069424459065 in that run), confirming React discarded the server markup for that subtree and rendered fresh on the client.

4. Scenario 2: branching on typeof window

A common pattern that looks like a safe environment check actually guarantees a mismatch, because it renders one thing on the server (where window is undefined) and another on the client (where it exists):

function Scenario2() {
  const isBrowser = typeof window !== 'undefined';
  return <div>{isBrowser ? 'Client rendered' : 'Server rendered'}</div>;
}

Measured console output, same shape as scenario 1:

Warning: Text content did not match. Server: "Server rendered" Client: "Client rendered"
    at div
    at Scenario2
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.

Same two uncaught page errors as scenario 1 followed, and the final DOM read Client rendered. This pattern is common in code migrated from client-only React: a typeof window or typeof document check feels like it's "handling" SSR, but it actually causes the exact mismatch hydration warns about.

5. The fix: defer the dynamic part to useEffect

Render the same, deterministic markup on both server and client for the first pass, then switch to the dynamic value only after mount, using useEffect plus state:

function Scenario3() {
  const [val, setVal] = useState(null);
  useEffect(() => { setVal(Math.random()); }, []);
  return <div>{val === null ? 'Random: (pending)' : 'Random: ' + val}</div>;
}

useEffect only runs in the browser, after hydration completes, so the server HTML and the client's first render both show Random: (pending). There is nothing to mismatch. We ran the same Playwright check against this version: zero console errors or warnings. The page hydrated cleanly, then updated to a real random value (Random: 0.8524432852212221 in that run) in a normal post-hydration state update, which is not a hydration error.

The same pattern fixes the typeof window case: compute a mounted boolean in state, set it to true inside useEffect, and render the server-safe branch until mounted is true.

6. Next.js specifics (per Next.js docs, not independently measured)

Next.js wraps the same React warning in its own error overlay, titled "Text content does not match server-rendered HTML" or "Hydration failed because the server rendered HTML didn't match the client," and in recent versions prints a diff of the server vs. client markup in the terminal and browser overlay. The underlying cause and fix are identical to what we measured above: the overlay is cosmetic, the mechanism is plain React hydration. Next.js also warns specifically about browser extensions injecting attributes (e.g. cz-shortcut-listen from Grammarly) before React runs, which look like a mismatch but come from outside your code; suppressHydrationWarning on that one element is the documented workaround for that specific case.

7. Other common causes

8. Checklist to fix and prevent it

Frequently asked questions

What does "Hydration failed because the server rendered HTML didn't match the client" mean?

It means React rendered different markup on the server than it got on the client's first render. We measured the underlying React warning directly: "Warning: Text content did not match. Server: ... Client: ...", followed by React discarding and re-rendering that subtree on the client.

Is a hydration error a bug in React or in my code?

In my code, almost always. React expects the server and client render to produce identical output on the first pass. In our tests, calling Math.random() in render and branching on typeof window both produced the mismatch purely from component code, with no React defect involved.

Does a hydration mismatch break my page?

Not fatally, but it costs performance and can flash. In our tests React threw the server-rendered subtree away and re-rendered it client-side, per the message 'the entire root will switch to client rendering' for errors outside a Suspense boundary. The page still worked, but lost the benefit of SSR for that part.

How do I fix a Math.random() or Date.now() hydration error?

Render a fixed placeholder on both server and client for the first paint, then set the real value inside useEffect, which only runs in the browser after hydration. We verified this produces zero console errors or warnings, versus two warnings and two uncaught errors without the fix.

Is this the same error in plain React and in Next.js?

The underlying mechanism is identical; we measured it with plain react-dom/server and react-dom/client. Next.js wraps the same warning in its own error overlay with a visual diff, per the Next.js docs — we did not run an actual Next.js project to confirm the overlay's exact wording.

Written with AI assistance. Measured on 2026-10-02 with React 18.3.1, react-dom 18.3.1, Express 5.2.1 and Node.js 24.21.0, using the raw react-dom/server + react-dom/client APIs (the same APIs Next.js's App Router builds on) and headless Chromium 153.0.8010.12 via Playwright. No Next.js project was run; the Next.js-specific wrapper message is from the Next.js docs, not measured. PlainHeaders is our extension; React, Express, Node.js and Chromium are free.