What causes hydration mismatches in Next.js?
Hydration mismatches happen when server-rendered HTML doesn't match the client's first render. Common causes: time/locale, random IDs, browser-only APIs, third-party DOM mutations.
Hydration is the bridge between the server-rendered HTML the browser receives in the initial document and the React component tree that will run on the client. The server renders HTML and ships it; the client downloads the JS bundle, then React walks the existing DOM and attaches state, props, and event listeners without re-creating the nodes. The hard constraint is that React expects the first client render to produce byte-for-byte the same virtual tree the server produced. Any divergence is a "hydration mismatch."
In React 18 / Next 13+, a mismatch triggers a warning in dev, and in production React bails out of hydrating the affected boundary, throwing away the server HTML for that subtree and re-rendering it on the client. The result: a visible flicker, a slower TTI, and (worse) lost SEO-relevant DOM if the mismatch is high in the tree. In Next.js App Router with React Server Components, mismatches in a Server Component cascade because all the children also lose their server-rendered output.
Common causes:
- Non-deterministic values.
Date.now(),Math.random(),crypto.randomUUID(),new Date().toLocaleString()(locale and timezone differ between server and client), or formatters that useIntlwithout an explicit locale. - Environment branching.
typeof window !== 'undefined' ? <Foo /> : <Bar />produces different trees by design — the SSR pass renders<Bar />, the client renders<Foo />. Same fornavigator.userAgentchecks. - Reading client storage during render.
localStorage,sessionStorage, cookies (when accessed through document.cookie), orwindow.matchMedia. The server has no access; the client does. - Third-party DOM mutators. Grammarly injects
data-gr-attributes; Dark Reader and 1Password rewrite styles and add attributes; ad scripts insert iframes. If they run before* hydration, the DOM React sees differs from the HTML it emitted. - Conditional CSS classes driven by user agent, screen size, or feature detection.
- Date / number / currency formatting without explicit
{ timeZone: 'UTC', locale: 'en-US' }— Node and the browser disagree. - Whitespace and HTML structure: invalid nesting like
<p><div>will be silently fixed by the browser parser, making the DOM diverge from React's expected tree. Math.random()-based IDs. React 18 providesuseId()precisely to solve this: it generates IDs that are stable across server and client.
Diagnostic workflow. The React/Next error message in dev mode tells you the offending text or attribute. Set a breakpoint, compare document.documentElement.outerHTML between the SSR response (View Source) and the rendered DOM (DevTools Elements). Any difference there is your bug. For "browser extension" cases, the diff will show attributes you didn't write.
Fixes:
- Move client-only state into
useEffect. Rendernull(or a skeleton) on the server and the real value after mount. The two-pass approach trades a layout flash for correctness. Pattern:const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) return null;. suppressHydrationWarningon a leaf you know will differ — typically a live timestamp. Only suppresses one level deep; keep the scope tight.dynamic(() => import('./X'), { ssr: false })in Next.js for components that fundamentally can't SSR (charts that need window.matchMedia, libraries that hitdocumentat import time).- Always pass an explicit
timeZoneandlocaletoIntl.DateTimeFormat,toLocaleString, currency formatters. - Use
useId()for generated IDs (form a11y, ARIA targets). - Tolerate extension attribute injection with
suppressHydrationWarningon<html>or<body>, since you can't prevent it. - Pass server-known values down as props rather than re-deriving them in the client component (timezone, locale, A/B variant, user agent class).
The mental model: SSR is a deterministic function of the request. Anything that introduces randomness or relies on client-only state must be deferred to useEffect or marked as client-only.
Code
Follow-up questions
- •How does React 18 partial hydration handle a mismatch?
- •When should you reach for `dynamic({ ssr: false })`?
- •Why does `useId` exist?
Common mistakes
- •Sprinkling `suppressHydrationWarning` everywhere — it hides real bugs.
- •Reading from `localStorage` directly in component body.
Performance considerations
- •Hydration mismatches cause full client re-render of a subtree → defeats the SSR perf win.
- •Streaming SSR + Suspense lets you defer hydration of below-the-fold sections.
Edge cases
- •Browser extensions injecting attributes (e.g. Grammarly's `data-gramm`) can't be eliminated — must be suppressed.
- •Whitespace-sensitive HTML (e.g., `<table>` parser differences) can cause silent mismatches.
Real-world examples
- •A theme provider reading `prefers-color-scheme` from cookie + media query is a classic hydration hazard — solve by setting the theme class on `<html>` *before* React boots, via a small inline script.