window/localStorage in render, (2) non-deterministic values
(Date.now(), Math.random()), (3) invalid HTML nesting
(<div> inside <p>), (4) browser extensions modifying DOM, (5)
conditional rendering based on client-only state.useEffect +
useState to defer it past hydration, or use next/dynamic with
{ ssr: false }.typeof window !== 'undefined' in render — it
returns false on server and true on client, causing mismatch. Use
useEffect instead.<html> or <body> —
this hides real bugs and silently degrades to client-side rendering in production. [src5]hydrateRoot() — legacy ReactDOM.hydrate() was removed in
React 19. [src1, src8]suppressHydrationWarning only works one level deep — it does not suppress warnings for
child elements, only the element's own attributes and text content. [src5]next/dynamic with ssr: false eliminates hydration risk but also eliminates SSR
SEO benefits for that component — only use for truly client-only content (maps, charts, editors). [src2, src7]<script>,
<link>, and <style> tags, but older React versions do not. [src8]| # | Cause | Likelihood | Signature | Fix |
|---|---|---|---|---|
| 1 | Browser API in render (window, localStorage) |
~30% of cases | Text content does not match server-rendered HTML |
Wrap in useEffect + useState [src1, src4] |
| 2 | Non-deterministic values (Date.now(), Math.random()) |
~20% of cases | Expected server HTML to contain a matching text |
Render placeholder on server, set real value in useEffect [src1, src3] |
| 3 | Invalid HTML nesting (<div> inside <p>, nested
<a>) |
~15% of cases | In HTML, <div> cannot be a descendant of <p> |
Fix HTML structure — use <span> or restructure [src2, src3] |
| 4 | Browser extensions / CDN modifying DOM | ~10% of cases | Mismatch on elements you didn't write | Test in incognito mode; disable Cloudflare Auto Minify [src2, src7] |
| 5 | Conditional rendering from client-only state | ~10% of cases | Component renders differently on server vs client | Use useEffect to toggle visibility after mount [src4, src8] |
| 6 | Third-party scripts injecting DOM nodes | ~5% of cases | Extra <script> or <style> tags in head/body |
Move scripts to useEffect or use next/script [src2, src6] |
| 7 | CSS-in-JS style extraction mismatch | ~4% of cases | className differs between server and client |
Configure SSR style extraction (e.g., styled-components SSR setup) [src7]
|
| 8 | Stale cached HTML served with new JS bundle | ~3% of cases | Mismatch after deployment | Purge CDN cache; set proper Cache-Control headers [src7]
|
| 9 | Whitespace or text node differences | ~2% of cases | Text content does not match on whitespace |
Remove extra whitespace in JSX; check template literals [src3] |
| 10 | Date/number formatting with locale differences | ~1% of cases | Number or date formatted differently | Use Intl APIs with explicit locale or defer to useEffect [src4] |
START
├── Error message contains "cannot be a descendant of"?
│ ├── YES → Invalid HTML nesting. Fix: restructure JSX (no <div> in <p>, no nested <a>) [src2]
│ └── NO ↓
├── Error mentions specific text content mismatch?
│ ├── YES → Check if mismatched value is dynamic (date, random, locale)
│ │ ├── Dynamic value → Wrap in useEffect + useState [src1, src4]
│ │ └── Static value → Check for browser API usage (window, localStorage) ↓
│ └── NO ↓
├── Mismatch on elements you didn't write (extra tags)?
│ ├── YES → Browser extension or third-party script. Test in incognito [src2, src7]
│ └── NO ↓
├── Using `typeof window !== 'undefined'` in render logic?
│ ├── YES → Move to useEffect — this check differs server vs client [src4]
│ └── NO ↓
├── Using CSS-in-JS (styled-components, Emotion)?
│ ├── YES → Configure SSR style extraction. Check className mismatch [src7]
│ └── NO ↓
├── Error only appears after deployment (not in dev)?
│ ├── YES → Stale cache. Purge CDN, rebuild, verify Cache-Control [src7]
│ └── NO ↓
├── Using Next.js App Router?
│ ├── YES → Ensure client-only components have "use client" directive [src2, src6]
│ └── NO ↓
└── DEFAULT → Use React DevTools to compare server HTML (View Source) vs client DOM [src6]
Open browser DevTools Console. React 19 and Next.js 15 show a detailed diff of server vs client HTML with source code locations and fix suggestions. For older versions, the error message points to the mismatched text or element. [src6, src8]
# View the raw server-rendered HTML (before hydration) in browser
# Right-click → View Page Source (NOT Inspect Element)
# Compare this HTML with what React renders in the DOM
Verify: Check if the error appears in the console → look for Hydration failed
or Text content does not match.
Search your component for any usage of window, document, localStorage,
sessionStorage, navigator, or other browser-only globals outside of
useEffect. [src1, src4]
// ❌ WRONG — window doesn't exist on the server
function ThemeToggle() {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return <button>{isDark ? '🌙' : '☀️'}</button>;
}
// ✅ CORRECT — defer to useEffect
function ThemeToggle() {
const [isDark, setIsDark] = useState(false); // same on server + client initially
useEffect(() => {
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);
return <button>{isDark ? '🌙' : '☀️'}</button>;
}
Verify: Component renders without hydration error → same content on server and client initial render.
Check JSX for elements that violate HTML content model rules. Common violations: <div>
inside <p>, <p> inside <p>, nested
<a> tags, block elements inside inline elements. [src2, src3]
// ❌ WRONG — <div> cannot be a descendant of <p>
<p>Hello <div className="badge">World</div></p>
// ✅ CORRECT — use <span> for inline or restructure
<p>Hello <span className="badge">World</span></p>
// ❌ WRONG — nested anchor tags
<a href="/page"><a href="/other">Link</a></a>
// ✅ CORRECT — single anchor
<a href="/page">Link</a>
Verify: Run npx next build → no hydration warnings in build output.
Values like dates, random numbers, and IDs change between server and client render. Render a consistent placeholder first, then update client-side. [src1, src3]
// ❌ WRONG — different time on server vs client
function Timestamp() {
return <span>{new Date().toLocaleTimeString()}</span>;
}
// ✅ CORRECT — show placeholder until client mounts
function Timestamp() {
const [time, setTime] = useState('');
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 1000);
return () => clearInterval(timer);
}, []);
return <span>{time || 'Loading...'}</span>;
}
Verify: View Page Source shows Loading... → after hydration shows
the actual time.
For components that simply cannot work on the server (maps, charts, editors), use Next.js dynamic import with SSR disabled. [src2, src7]
import dynamic from 'next/dynamic';
// Component only loads on the client — no hydration mismatch possible
const MapView = dynamic(() => import('./MapView'), {
ssr: false,
loading: () => <div className="map-placeholder">Loading map…</div>,
});
export default function Page() {
return <MapView lat={51.5} lng={-0.1} />;
}
Verify: No hydration error → map renders only on client after JavaScript loads.
Build a generic wrapper component for any client-only content. [src4, src8]
'use client';
import { useState, useEffect } from 'react';
function ClientOnly({ children, fallback = null }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? children : fallback;
}
// Usage
<ClientOnly fallback={<span>--</span>}>
<span>{window.innerWidth}px</span>
</ClientOnly>
Verify: Server HTML shows fallback → client renders the real content after hydration.
// Input: Component reading user's theme preference from localStorage
// Output: No hydration mismatch — consistent server/client render
'use client';
import { useState, useEffect } from 'react';
export default function ThemeProvider({ children }) {
// Start with a default that matches on both server and client
const [theme, setTheme] = useState('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
// Only runs on client after hydration
const saved = localStorage.getItem('theme') || 'light';
setTheme(saved);
setMounted(true);
}, []);
useEffect(() => {
if (mounted) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
}, [theme, mounted]);
return (
<div data-theme={mounted ? theme : undefined}>
{children}
{mounted && (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
)}
</div>
);
}
// Input: Component that conditionally renders based on viewport size
// Output: No hydration mismatch — deferred client measurement
import { useState, useEffect } from 'react';
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize(); // Set initial value
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
export default function ResponsiveNav() {
const { width } = useWindowSize();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Server and first client render: show nothing (consistent)
if (!mounted) return <nav aria-label="navigation">Loading…</nav>;
// After hydration: render based on actual width
return (
<nav aria-label="navigation">
{width > 768 ? <DesktopNav /> : <MobileNav />}
</nav>
);
}
// Input: Component detecting mobile vs desktop from user agent
// Output: Hydration-safe implementation using server-side detection
import { useLoaderData } from '@remix-run/react';
import { json } from '@remix-run/node';
// Server: detect device from request headers
export async function loader({ request }) {
const ua = request.headers.get('user-agent') || '';
const isMobile = /iPhone|iPad|Android|Mobile/i.test(ua);
return json({ isMobile });
}
// Client: use the same value the server used — no mismatch
export default function Layout() {
const { isMobile } = useLoaderData();
return (
<div className={isMobile ? 'mobile-layout' : 'desktop-layout'}>
{isMobile ? <MobileNav /> : <DesktopNav />}
</div>
);
}
// Input: React 19 app with hydration mismatch
// Output: Detailed error diff in console with source code location
// React 19 automatically provides:
// 1. A single consolidated error (not multiple)
// 2. Diff showing exact server vs client HTML
// 3. Source code location of the mismatched component
// 4. Suggested fix in the error message
// To enable maximum diagnostic detail in development:
import { hydrateRoot } from 'react-dom/client';
const root = hydrateRoot(document.getElementById('root'), <App />, {
onRecoverableError(error, errorInfo) {
// Log hydration recovery errors to your monitoring service
console.error('Hydration recovery:', error);
console.error('Component stack:', errorInfo.componentStack);
},
});
// ❌ BAD — this returns false on server and true on client [src4]
function MyComponent() {
if (typeof window !== 'undefined') {
return <div>{window.innerWidth}px wide</div>;
}
return <div>Loading</div>;
}
// Server renders "Loading", client renders "1024px wide" → MISMATCH
// ✅ GOOD — both server and client render the same initial HTML [src4, src8]
function MyComponent() {
const [width, setWidth] = useState(null);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <div>{width !== null ? `${width}px wide` : 'Loading'}</div>;
}
// Server renders "Loading", client initially renders "Loading" too → NO MISMATCH
// After useEffect: updates to "1024px wide"
// ❌ BAD — different timestamps on server vs client [src1, src3]
function Footer() {
return <p>Page rendered at {new Date().toISOString()}</p>;
}
// ✅ GOOD (option 1) — suppress for intentionally different timestamps [src5]
function Footer() {
return <p suppressHydrationWarning>Page rendered at {new Date().toISOString()}</p>;
}
// ✅ GOOD (option 2) — defer to client [src8]
function Footer() {
const [time, setTime] = useState('');
useEffect(() => setTime(new Date().toISOString()), []);
return <p>Page rendered at {time}</p>;
}
// ❌ BAD — browser auto-corrects this, React complains [src2, src3]
function Card() {
return (
<p>
<div className="card-body"> {/* div inside p is invalid HTML */}
<h2>Title</h2>
</div>
</p>
);
}
// ✅ GOOD — proper semantic HTML [src2]
function Card() {
return (
<div>
<div className="card-body">
<h2>Title</h2>
</div>
</div>
);
}
// ❌ BAD — hides real bugs, not a fix [src5]
// Some developers wrap entire app with suppressHydrationWarning
<html suppressHydrationWarning>
<body suppressHydrationWarning>
<App />
</body>
</html>
// ✅ GOOD — only suppress on the specific element with known, intentional mismatch [src5]
<time suppressHydrationWarning dateTime={new Date().toISOString()}>
{new Date().toLocaleDateString()}
</time>
// Use sparingly — only for truly unavoidable cases like live timestamps
typeof window guard in render logic: Returns false on server
/ true on client — the guard itself causes the mismatch. Fix: use useEffect +
useState, not conditional rendering. [src4]window internally. Fix: wrap in
next/dynamic with ssr: false. [src2, src7]<script> and <link> tags gracefully. [src2, src8]ServerStyleSheet setup. Fix: follow the styled-components SSR guide.
[src7]useId() incorrectly across server/client trees: React's
useId() generates deterministic IDs, but only if the component tree is identical.
Conditional rendering that changes tree shape breaks it. Fix: keep the component tree consistent between
server and client. [src1]next-themes library causes
hydration mismatch if you render theme-dependent content without checking mounted state
first. Fix: use the mounted check from useTheme() before rendering
theme-specific UI. [src7]# View raw server-rendered HTML (before hydration)
curl -s https://localhost:3000/your-page | head -100
# Build and test in production mode (some mismatches only appear in prod)
npx next build && npx next start
# Search codebase for common hydration mismatch causes
grep -rn "typeof window" --include="*.tsx" --include="*.jsx" src/
grep -rn "localStorage\|sessionStorage" --include="*.tsx" --include="*.jsx" src/
grep -rn "document\." --include="*.tsx" --include="*.jsx" src/
grep -rn "navigator\." --include="*.tsx" --include="*.jsx" src/
grep -rn "Date.now\|new Date()" --include="*.tsx" --include="*.jsx" src/
grep -rn "Math.random" --include="*.tsx" --include="*.jsx" src/
# Check for invalid HTML nesting (install html-validate)
npx html-validate --ext .html,.tsx,.jsx src/
# Test in incognito mode to rule out browser extensions
# Chrome: Ctrl+Shift+N → navigate to page → check console
| Version | Hydration Behavior | Key Changes |
|---|---|---|
| React 19 + Next.js 15 | Current | Improved error diffs with source code + fix suggestions, tolerates third-party DOM
modifications, selective hydration, onRecoverableError callback, single
consolidated error message [src6, src8] |
| React 18 + Next.js 14 | Supported | hydrateRoot API, streaming SSR, automatic batching, Suspense for SSR [src1] |
| React 18 + Next.js 13 | Supported | App Router introduced, Server Components reduce hydration scope [src2] |
| React 17 + Next.js 12 | Legacy | ReactDOM.hydrate(), less descriptive error messages |
| React 16.x | EOL | ReactDOM.hydrate() — warnings only, no enforcement |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Console shows "Hydration failed" or "Text content does not match" | Component renders wrong data (no hydration error) | Debug state management / props |
| Error mentions "cannot be a descendant of" | Component doesn't render at all (white screen) | See React white screen guide |
| App works in dev but breaks in prod build | Error is "Too many re-renders" | See re-renders guide |
| Server HTML (View Source) differs from client DOM | Server returns 500 error (no HTML at all) | Debug server-side error handling |
<script>, <link>, and <style> tags injected by
browser extensions. If React 19 needs to re-render the entire document due to an unrelated mismatch, it
leaves third-party stylesheets in place. Older React versions throw hard errors on these. [src8]suppressHydrationWarning only works one level deep — it suppresses the warning for that
element's attributes and text content, not for its children. [src5]app/) uses React Server Components by default. Only components with
"use client" directive are hydrated. Moving logic to Server Components eliminates hydration
risk entirely for those components. [src2, src6]useEffect pattern introduces a flash of default content (FODC). For theme switching,
consider using a blocking <script> in <head> to set the
data-theme attribute before React hydrates. [src4]onRecoverableError callback on hydrateRoot — use this to
log hydration recovery events to monitoring services without crashing the app. [src8]