Skip to content

Next.js hydration mismatch in production: finding the actual cause

  • Next.js
  • React
  • Hydration
  • SSR
  • Debugging

You ship a build. Locally everything is fine. Then production starts logging this:

Text content did not match server-rendered HTML.
Warning: Expected server HTML to contain a matching <div> in <div>.
Hydration failed because the initial UI does not match what was rendered on the server.
There was an error while hydrating. Because the error happened outside of a
Suspense boundary, the entire root will switch to client rendering.

That last line is the expensive one. React did not patch up the difference — it threw away the server-rendered markup for that root and re-rendered the whole thing on the client. The page you paid to server-render arrives, flashes, and gets replaced. Your LCP gets worse, your layout shifts, and any state that lived in that tree resets.

The warning tells you a mismatch happened. It rarely tells you which of your components caused it. This is how to find out.

Why hydration mismatches only appear in production

Three reasons this reproduces badly on your laptop:

  1. Development renders twice. React StrictMode double-invokes render in dev, which papers over some ordering differences and surfaces others that never happen in production.
  2. Your machine is one timezone, one locale, one clock. Most mismatches are environmental. The server and the browser agree on your laptop because they are your laptop.
  3. Dev has no CDN. Cached HTML served to a different user, at a different time, with different cookies, is a different string than the one your dev server just produced.

So the first rule: reproduce it against a production build, not next dev.

yarn build && yarn start
# then open the page in a fresh incognito window

If it does not reproduce, force the conditions that differ. This is usually enough:

# Pretend to be somewhere else, in another language
TZ=Asia/Tokyo yarn start

Then in Chrome DevTools, Sensors → Location to override geolocation, and ⋮ → More tools → Sensors to set a different timezone for the browser only. You want the server and the client to disagree, on purpose. If the bug appears when they do, the cause is environmental rather than logical, which narrows the search considerably.

How to find the component that is causing it

React's warning in development names the offending text. In production builds the error is minified and far less useful, so do the diagnosis in a dev build with the environmental difference forced:

TZ=Asia/Tokyo yarn dev

React 19 prints a diff for mismatches, which looks roughly like this:

- Server: "30/08/2026"
+ Client: "31/08/2026"

That is usually enough to identify the component. When it is not — because the mismatch is structural rather than textual — the reliable technique is bisection against the server output:

# Get the server's HTML exactly as the browser receives it
curl -s http://localhost:3000/the-broken-page > server.html

Then open the same URL in the browser, copy the hydrated DOM from the Elements panel, and diff them. curl gives you what the server sent; the Elements panel shows what React decided it should have been. The first divergence in that diff is your component.

For a large page, narrow it faster by suspending subtrees one at a time. Wrapping a subtree in a Suspense boundary contains the damage — React re-renders only that boundary on the client instead of the whole root — which is both a diagnostic and, sometimes, an acceptable fix:

<Suspense fallback={<CommentsSkeleton />}>
  <Comments postId={id} />
</Suspense>

If the "entire root will switch to client rendering" line disappears and the warning now names the boundary, you have located the subtree.

Cause 1: dates and times formatted during render

This one is worth checking first, because it needs no unusual conditions to trigger - only a visitor in a different timezone from your server.

// Broken: the server is in UTC, the visitor is in Asia/Tokyo.
// Near midnight these produce different strings — or different days.
export function PublishedAt({ iso }: { iso: string }) {
  return <span>{new Date(iso).toLocaleDateString()}</span>
}

toLocaleDateString() with no arguments uses the runtime's locale and timezone. On the server that is your container — almost always UTC. In the browser it is whatever the visitor has. Those agree on your laptop and disagree for a real user in Tokyo.

Fix it by making the output independent of where it runs:

// Fixed: same string everywhere, because nothing is inferred from the runtime.
export function PublishedAt({ iso }: { iso: string }) {
  const text = new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-GB', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
  })
  return <time dateTime={iso}>{text}</time>
}

Pin the locale, pin the timezone. If you genuinely need the visitor's local time — "3 hours ago", a meeting time — then it cannot be server-rendered, and you should say so explicitly:

'use client'
import { useSyncExternalStore } from 'react'

const subscribe = () => () => {}

// Renders the server-safe string first, then swaps to local time after
// hydration. No mismatch, because the first client render matches the server.
export function LocalTime({ iso }: { iso: string }) {
  const isClient = useSyncExternalStore(
    subscribe,
    () => true,   // client snapshot
    () => false   // server snapshot
  )
  if (!isClient) return <time dateTime={iso}>{iso}</time>
  return <time dateTime={iso}>{new Date(iso).toLocaleString()}</time>
}

useSyncExternalStore is the right primitive here — better than the common useState(false) + useEffect pattern, because it tells React explicitly that the server and client snapshots differ, rather than relying on an effect to run after a render React thought was correct.

Cause 2: Math.random(), Date.now(), and crypto.randomUUID() in render

Any nondeterminism in the render path produces a different tree on each call, and hydration compares two calls.

// Broken: a different id on the server than in the browser.
function Field({ label }: { label: string }) {
  const id = `field-${Math.random().toString(36).slice(2)}`
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  )
}

React has a purpose-built hook for exactly this:

import { useId } from 'react'

function Field({ label }: { label: string }) {
  const id = useId()   // stable across server and client
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  )
}

The same applies to shuffling. [...items].sort(() => Math.random() - 0.5) in a component body will mismatch every time. Shuffle in getStaticProps/getServerSideProps and pass the ordered array down, or shuffle in an effect after mount.

Cause 3: reading window, localStorage, or matchMedia during the first render

// Broken: `typeof window` is a lie the server tells, and hydration catches it.
function Sidebar() {
  const collapsed =
    typeof window !== 'undefined' &&
    localStorage.getItem('sidebar') === 'collapsed'
  return <aside className={collapsed ? 'w-16' : 'w-64'}>…</aside>
}

The server always renders w-64. A returning visitor whose sidebar was collapsed hydrates to w-16. Mismatch, on exactly the users who have used the site before.

The honest fix is to render the server's version first and correct after mount:

'use client'
import { useEffect, useState } from 'react'

function Sidebar() {
  const [collapsed, setCollapsed] = useState(false) // matches the server
  useEffect(() => {
    setCollapsed(localStorage.getItem('sidebar') === 'collapsed')
  }, [])
  return <aside className={collapsed ? 'w-16' : 'w-64'}>…</aside>
}

That costs one frame of the wrong width. If that flash matters — and for a theme toggle it usually does — the value has to be available before React runs, which means a blocking inline script that sets a class on <html>, and CSS that reads it. That is precisely what next-themes does, and it is why theme switching is one of the few legitimate uses of an inline script in _document.

When this technique does not apply: if the value changes what the page means rather than how it looks — a logged-in vs logged-out view, a paywalled article — do not render the wrong version first. Read the cookie on the server and render the correct tree from the start. A flash of the wrong content is worse than a slower response, and for a paywall it is a bug with revenue attached.

Cause 4: invalid HTML nesting

This one produces the Expected server HTML to contain a matching <div> in <div> variant, and it confuses people because the component looks fine.

// Broken: <div> is not allowed inside <p>. The browser's parser silently
// closes the <p> before the <div>, so the DOM does not match React's tree.
<p>
  Read the <div className="badge">docs</div> first.
</p>

The server sends valid-looking JSX-generated HTML; the browser's HTML parser then restructures it according to the spec before React ever sees it. React compares its tree against the restructured DOM and finds them different.

// Fixed: inline element inside a paragraph.
<p>
  Read the <span className="badge">docs</span> first.
</p>

The usual offenders: <div> inside <p>, block elements inside <button>, <a> inside <a>, and anything that is not <tr>/<tbody> directly inside <table>. If a mismatch names an element you did not think was interesting, check the nesting rules before you check your logic.

Cause 5: browser extensions and injected markup

Worth knowing so you stop hunting a bug that is not yours. Password managers, ad blockers and translation extensions inject attributes and nodes into the DOM before React hydrates. You will see reports of mismatches you cannot reproduce, often mentioning attributes you never wrote.

This is why React ignores some attribute differences on <body> and <html>, and why suppressHydrationWarning exists:

// The timestamp is intentionally different; do not warn about it.
<time suppressHydrationWarning>{new Date().toISOString()}</time>

Use it sparingly, and only one element deep — it does not cascade to children, and it silences a real class of bug. If you find yourself adding it to a layout wrapper to make the console quiet, you have hidden the problem rather than fixed it. Reach for it when the difference is genuinely intentional, not when you are tired of the warning.

A quick checklist

When a mismatch appears, in the order that finds it fastest:

  1. Reproduce against yarn build && yarn start, with TZ set to something far away.
  2. Read the React 19 diff — for a textual mismatch it names the differing string directly.
  3. curl the page, diff against the hydrated DOM in the Elements panel.
  4. Grep the suspect subtree for toLocaleDateString, Date.now, Math.random, typeof window, localStorage, matchMedia.
  5. Validate the HTML nesting of anything the warning names structurally.
  6. Only then consider suppressHydrationWarning, on one element, with a comment saying why.

When this is worth outside help

A mismatch that turns out to be one of the five above is usually an afternoon's work once you have located it. It is worth bringing someone in when the mismatch only appears for a fraction of real users and you cannot reproduce it, when it is coming from inside a dependency rather than your own code, or when the "entire root will switch to client rendering" line means you are paying for SSR and shipping a client-rendered page anyway. That last case is the expensive one, because the cost is invisible in every metric except the ones your users feel.

Intermittent SSR failures under real traffic tend to travel with other problems — I wrote up what stabilizing a high-traffic marketplace involved, where hydration was one symptom among several and the memory leak underneath it was the actual story.

That is the kind of thing a Production Health Audit is for: I read the code, run it under conditions that match your users rather than your laptop, and hand back a written list of what is actually breaking and what it costs — with the fix for each one.