Debugging 2026-07-11

Hydration Mismatches in Next.js: Causes Ranked by Frequency

If you need to debug a hydration mismatch in Next.js, the good news is that the cause is almost always one of five things. I’ve been fixing these since the ReactDOM.hydrate days, and the distribution hasn’t changed much: it’s usually a date, sometimes it’s window, occasionally it’s a browser extension you can’t do anything about, and once in a blue moon it’s the HTML parser silently rewriting your markup.

Here they are, ranked by how often they’ve actually been the culprit in my experience. Start at #1 and work down.

#1: Dates, times, and locales

The server renders in UTC (or whatever your host’s timezone is — often UTC, sometimes not, which is its own fun surprise). The client renders in the user’s timezone. Any component that formats a date will produce different text on each side:

// Server (UTC): "Jul 11, 2026, 2:00 AM"
// Client (PDT): "Jul 10, 2026, 7:00 PM"
function PostDate({ iso }) {
  return <time dateTime={iso}>{new Date(iso).toLocaleString()}</time>
}

Same problem with locales: the server formats with its default locale, the client uses the user’s. 1,234.56 versus 1.234,56. React diffs the text, finds a mismatch, and complains.

The fix that actually works: render something deterministic on the server, then swap in the localized version after mount.

function PostDate({ iso }) {
  const [text, setText] = useState(iso.slice(0, 10)) // deterministic
  useEffect(() => {
    setText(new Date(iso).toLocaleString())
  }, [iso])
  return <time dateTime={iso} suppressHydrationWarning>{text}</time>
}

Or pass an explicit locale and timezone to Intl.DateTimeFormat so both environments produce identical output. Or render relative times (“3 hours ago”) computed from a timestamp you pass down as a prop — but compute the anchor time on the server and pass it too, otherwise you’ve just moved the nondeterminism.

#2: Rendering on client-only state

window, localStorage, navigator, matchMedia — none of these exist during server rendering. The classic pattern:

function Sidebar() {
  const collapsed = typeof window !== 'undefined'
    && localStorage.getItem('sidebar') === 'collapsed'
  return <aside className={collapsed ? 'w-12' : 'w-64'}>...</aside>
}

The typeof window guard prevents a crash, but it guarantees a mismatch for any user with a collapsed sidebar: the server renders w-64, the client renders w-12. The guard is the bug wearing a safety vest.

The fix: client-only state must be applied after hydration, full stop.

function Sidebar() {
  const [collapsed, setCollapsed] = useState(false) // matches server
  useEffect(() => {
    setCollapsed(localStorage.getItem('sidebar') === 'collapsed')
  }, [])
  // ...
}

Yes, this means one render with the wrong value. If that flash is unacceptable, the state needs to move somewhere the server can read it — a cookie — so both sides render the same thing. That’s the honest trade: useEffect and accept a flash, or cookies and do the plumbing.

#3: Browser extensions mutating the DOM

This one is maddening because it’s not your code. Password managers inject icons into inputs. Translation extensions wrap text nodes in <font> tags. Ad blockers delete elements. Coupon extensions inject entire widgets. All of this can happen between the HTML arriving and React hydrating it, so React walks the DOM and finds nodes it never rendered.

You’ll know it’s this when the error only comes from a handful of users and you can’t reproduce it on any machine you own. I once spent most of a day on a mismatch that turned out to be a translation extension rewriting the entire page into Portuguese before hydration ran.

The fix: mostly, you don’t. React 18+ recovers from many extension-induced mismatches by falling back to client rendering for the affected subtree, which is ugly but survivable. What you should do is stop these errors from polluting your error tracking — filter reports where the mismatch text contains markers like <font> tags or known extension attribute names (data-gr-ext-installed, data-lastpass-icon-root, and friends), or at least tag them so they don’t page anyone.

#4: Non-deterministic values in render

Math.random(), Date.now(), crypto.randomUUID() — anything that returns a different value each call will differ between the server render and the client render:

// Guaranteed mismatch, every single time
<div id={`tooltip-${Math.random().toString(36).slice(2)}`}>

The usual motivation is generating unique IDs for accessibility attributes. React shipped a hook for exactly this:

const id = useId() // stable across server and client
<label htmlFor={id}>Email</label>
<input id={id} />

useId produces identical IDs on both sides because it’s derived from the component’s position in the tree, not from randomness. For anything else non-deterministic — A/B test bucketing, random featured items — either compute it on the server and pass it down, or defer it to an effect.

#5: Invalid HTML nesting

The sneakiest one. You write this:

<p>Prices start at <div className="badge">$19</div> per month</p>

React renders it fine as a string on the server. But <div> isn’t allowed inside <p>, so the browser’s HTML parser corrects your markup while parsing — it closes the <p> early and hoists the <div> out. Now the real DOM doesn’t match React’s expected tree, and hydration fails on markup that looks perfectly innocent in your editor.

The all-time classic is <p> inside <p>, which happens constantly when a CMS or markdown renderer emits paragraphs and you wrap the output in a <p> yourself. Also common: <div> inside <a> inside another <a>, and anything block-level inside <button>… wait, buttons actually allow most content — the ones to watch are p, a, and table elements.

The fix: fix the nesting. There’s no workaround; the parser will always win. Recent Next.js versions call out the offending element pair in the error message, which helps enormously. For older versions, paste your rendered HTML into the W3C validator and look for “element X not allowed as child of element Y”.

How to debug a hydration mismatch in Next.js, in order

When the error message alone doesn’t give it away:

  1. Read the diff. React 18.3+ and Next.js print a tree diff showing server vs. client output. The mismatched text usually names the culprit directly — if you see two different date strings, you’re done.
  2. Check the timezone. Set your dev machine’s timezone to something other than your server’s and reload. If the error appears locally, it’s cause #1.
  3. Reproduce in incognito with extensions off. If it vanishes, it’s #3, and you can stand down.
  4. Binary-search the page. Comment out half the page, reload, repeat. Crude, but it corners #4 and #5 fast — usually faster than reading every component with an emotion of suspicion.
  5. Validate the HTML. If the diff shows structure moving around rather than text differing, it’s #5.

About suppressHydrationWarning

React gives you an escape hatch:

<time suppressHydrationWarning>{new Date().toLocaleString()}</time>

It suppresses the warning for that element’s text and attributes — one level deep only, not the whole subtree. Two things to know before reaching for it.

It’s honest to use when the mismatch is intentional and cosmetic: a timestamp that’s allowed to differ by rendering environment, a “last synced” label, anything where the server value is a fine placeholder and the client value is simply more accurate. That’s what it was built for.

It’s dishonest when you’re using it to silence a mismatch you don’t understand. The warning isn’t the problem — the mismatch is, and it can cause real breakage: event handlers attached to the wrong nodes, state associated with the wrong list items, or React discarding the server HTML entirely and re-rendering the tree on the client, which torches the performance win you deployed SSR for in the first place. If you can’t explain why the two renders differ, suppressing the warning is just deleting the smoke detector.

One last thing: hydration errors in production are worth monitoring even when the page looks fine, because they’re the early warning for the class of failure where the page doesn’t look fine — the white screen of death, where a hydration crash takes down the whole root and your user gets a blank page. The mismatch you shrug off today is the outage report you get next quarter.

See the bug the way your user did

LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.

Try LogReplay free