Search nomadLab

Next.js 16 Hydration Errors: One Rule, Seven Causes, and Two Myths

React 19 did not turn hydration warnings into errors. They were always errors. What changed is that you now get one message with a diff instead of five without one, which makes the bug findable.

Updated

You upgraded to Next.js 16, ran the app, and the screen is red: Hydration failed because the server rendered HTML didn't match the client. The same code was fine on 15.

Two things get repeated about this that are not true, and both send people down the wrong path. React 19 did not promote hydration warnings to errors. React 18 threw on mismatches too, with Uncaught Error: Text content does not match server-rendered HTML. buried under a pile of duplicate warnings. What React 19 changed is the reporting: one error, with a diff showing which node differed and which side rendered what. The bug is not new. It just became findable.

The second myth is that Partial Prerendering is on by default in Next.js 16 and is dragging mismatches into the light. It is not on by default. PPR now ships inside the cacheComponents flag, which you set yourself in next.config.ts, and turning it on removed the old experimental.ppr flag and the experimental_ppr route segment config. If you have not set cacheComponents: true, PPR is not what is happening to you.

Versions here are Next.js 16.3.2 and React 19.2.8, checked on 22 August 2026.

The one rule everything follows from

Hydration compares exactly two renders: the markup the server produced, and the very first render on the client. That is the whole comparison window.

A sequence showing the hydration comparison window. The server render and the client's first render sit inside a marked region and must produce identical markup. Effects run after that region and are outside the comparison, which is why moving a non-deterministic value into useEffect resolves a mismatch. Only two renders are compared Server render to HTML Client first render Effects run must produce identical markup not compared Every fix is the same move: get the value that cannot agree out of the dashed box.
The mount-guard pattern is not a trick. Effects run after the comparison, so nothing inside one can cause a mismatch.

Once that is in your head, the fix for any cause is obvious before you know which cause it is. Render something both sides can agree on, then replace it in an effect.

What the docs actually list

The Next.js error page enumerates seven causes. Five are the ones everyone names. Two get skipped in most write-ups, and they are the two where the code looks correct.

Invalid HTML nesting. A <div> inside a <p>, a <ul> inside a <p>, an <a> inside an <a>, a <button> inside a <button>. The browser silently restructures invalid nesting while parsing, so the DOM React hydrates against is not the DOM the server serialized. Your data is perfectly deterministic and it still throws. This is the one people stare at longest, because the values in the diff look identical. The structure moved, not the text.

Browser-only APIs in render. window, document, localStorage, navigator. Undefined on the server, real in the browser.

Time-dependent APIs in render. new Date(), Date.now(), relative timestamps, countdowns. The server’s now and the client’s now are never the same instant.

Random values. Math.random() or a hand-rolled ID generator feeding an id, a key, or an ARIA attribute.

Browser extensions. Grammarly, password managers, and color pickers inject nodes into your markup before React gets to look at it.

Then the two nobody mentions:

typeof window !== 'undefined' checks in rendering logic. This is the counterintuitive one, because it looks like the careful thing to do. It is listed as a cause, not a fix. A guard like that is a branch that is guaranteed to evaluate differently on the two sides, which is precisely the thing hydration is checking for. It converts a crash into a mismatch and feels like progress.

A CDN or edge layer rewriting your HTML. The docs name Cloudflare Auto Minify specifically. If something between your server and the browser modifies the response, the HTML React hydrates against is not the HTML React generated, and no amount of reading your components will show it.

There is also an iOS-specific case worth knowing before you lose an afternoon to it. Safari on iOS detects phone numbers, dates, and addresses in text and converts them to links, which changes the DOM out from under hydration. The documented fix is a meta tag, not a code change:

<meta name="format-detection"
      content="telephone=no, date=no, email=no, address=no" />

Narrowing it down in a minute

Read the diff first. React 19’s error prints the component path and marks which side rendered what, so you get the offending string for free. Take it and search:

grep -rn "toLocaleTimeString\|toLocaleString\|new Date\|Date.now\|Math.random\|localStorage\|typeof window" src/

Three outcomes, and each points somewhere different. If the string traces to a date, a random value, or a browser API, you have it. If the string is fine but sits inside markup that a browser would rewrite, look at the nesting rather than the value. If the string is not in your source at all, stop reading components: that is an extension, a CDN rewriting the response, or iOS auto-detection, and none of those live in your repository.

Clearing .next and restarting is worth trying before a long hunt, purely because it costs ten seconds and rules out a stale build. It is not a documented cause and I would not spend more than one attempt on it.

The fixes

The mount guard, for dates, browser APIs, and anything else that cannot agree. Server and first client render both produce the placeholder, so they match, and the effect swaps in the real value afterwards.

"use client";
import { useState, useEffect } from "react";

function Clock() {
  const [time, setTime] = useState(null);

  useEffect(() => {
    const tick = () => setTime(new Date().toLocaleTimeString());
    tick();
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);

  return <span>{time ?? "--:--:--"}</span>;
}

useId for generated IDs. It exists precisely so that server and client agree on a value that has to be unique, and it is the right answer for htmlFor pairings and ARIA attributes.

One trap here has moved recently. React 19.1 changed the useId format from :r0: to «r0» so the values would be valid CSS selectors, and 19.2 changed it again, to a plain-ASCII form. If two copies of React resolve in the same tree, which is easy to do mid-migration in a monorepo, the two copies generate different formats and you get an ID mismatch from the hook that exists to prevent ID mismatches. Run npm ls react after any dependency change.

suppressHydrationWarning, narrowly. For a timestamp you genuinely cannot avoid rendering server-side, or content an extension injects. The docs are specific about its limits: it works one level deep, it does not apply to children, and React will not patch mismatched text content when it is set. That last part is the sting. It does not repair the difference, it stops React from telling you about it, so using it to clear a red screen you do not understand converts a visible error into wrong data.

dynamic with ssr: false, when a component simply has no business rendering on the server:

const NoSSR = dynamic(() => import("../components/no-ssr"), { ssr: false });

Suspense boundaries, if you did turn on cacheComponents. With it enabled, nothing is cached until you say so, and Next.js prerenders a static shell that streams dynamic content into place. A <Suspense> boundary is what tells it which parts stay out of the shell. The answer to a shell-versus-stream mismatch is drawing that boundary, not turning the flag back off.

Keeping the next one out

Build and run the production output locally before pushing. next build && next start exercises the real server render and hydration path; next dev is more forgiving and lets things through.

Run one CI job in a different timezone and locale. TZ=Asia/Tokyo costs nothing and catches the class of bug that passes on every machine in your office and fails for everyone in another region, because a server in UTC and a browser in Chicago disagree about what hour it is and what character separates the decimals.

If a timestamp has to render on the server, pin it. Intl.DateTimeFormat with an explicit timeZone produces the same string in both places by construction, which is a stronger guarantee than remembering not to leave it to the ambient locale.

None of this makes mismatches impossible. It moves them from a production incident to a CI failure, which is the only move that changes anything.

If you are looking at a red screen right now: read the diff, grep for the string, and if the string is not in your source, the problem is not in your source.

Keep reading