How to Fix React/Next.js Hydration Mismatch Errors
How do I fix React/Next.js hydration mismatch errors?
TL;DR
- Bottom line: Hydration mismatch occurs when server-rendered HTML differs from what
React generates on the client during hydration. The 5 most common causes: (1) browser-only APIs like
window/localStoragein 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. - Key tool/command: Wrap client-only code in
useEffect+useStateto defer it past hydration, or usenext/dynamicwith{ ssr: false }. - Watch out for: Accessing
typeof window !== 'undefined'in render — it returnsfalseon server andtrueon client, causing mismatch. UseuseEffectinstead. - Works with: React 18+, React 19, Next.js 13/14/15 (App Router and Pages Router), Gatsby, Remix.
Constraints
- Never suppress hydration warnings globally on
<html>or<body>— this hides real bugs and silently degrades to client-side rendering in production. [src5] - React 18+ requires
hydrateRoot()— legacyReactDOM.hydrate()was removed in React 19. [src1, src8] suppressHydrationWarningonly works one level deep — it does not suppress warnings for child elements, only the element's own attributes and text content. [src5]- In production mode, React silently falls back to full client-side re-render on mismatch — the error is hidden but the performance penalty (doubled rendering, lost SSR benefits) is real. [src1, src6]
next/dynamicwithssr: falseeliminates hydration risk but also eliminates SSR SEO benefits for that component — only use for truly client-only content (maps, charts, editors). [src2, src7]- jQuery, third-party scripts, and browser extensions that modify the DOM during or before hydration
trigger the same class of error — React 19 tolerates third-party
<script>,<link>, and<style>tags, but older React versions do not. [src8]
Quick Reference
| # | 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] |
Decision Tree
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]
Step-by-Step Guide
1. Identify the mismatched content
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.
2. Check for browser-only API usage in render
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.
3. Fix invalid HTML nesting
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.
4. Handle non-deterministic values
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.
5. Use dynamic imports for client-only components
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.
6. Create a reusable ClientOnly wrapper
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.
Code Examples
Next.js App Router: Fixing localStorage-based theme
// 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>
);
}
Next.js Pages Router: Fixing window-dependent rendering
// 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>
);
}
React + Remix: Handling user agent detection
// 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>
);
}
React 19: Using the improved error diagnostics
// 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);
},
});
Anti-Patterns
Wrong: Using typeof window check in render
// ❌ 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
Correct: Defer client-only content with useEffect
// ✅ 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"
Wrong: Rendering dates directly
// ❌ BAD — different timestamps on server vs client [src1, src3]
function Footer() {
return <p>Page rendered at {new Date().toISOString()}</p>;
}
Correct: Use suppressHydrationWarning or useEffect
// ✅ 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>;
}
Wrong: Invalid HTML nesting in JSX
// ❌ 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>
);
}
Correct: Use valid HTML structure
// ✅ GOOD — proper semantic HTML [src2]
function Card() {
return (
<div>
<div className="card-body">
<h2>Title</h2>
</div>
</div>
);
}
Wrong: Suppressing all hydration warnings globally
// ❌ BAD — hides real bugs, not a fix [src5]
// Some developers wrap entire app with suppressHydrationWarning
<html suppressHydrationWarning>
<body suppressHydrationWarning>
<App />
</body>
</html>
Correct: Fix the root cause, suppress only specific intentional mismatches
// ✅ 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
Common Pitfalls
typeof windowguard in render logic: Returnsfalseon server /trueon client — the guard itself causes the mismatch. Fix: useuseEffect+useState, not conditional rendering. [src4]- Third-party component libraries without SSR support: Libraries like some rich text
editors, maps, or charting tools access
windowinternally. Fix: wrap innext/dynamicwithssr: false. [src2, src7] - Cloudflare Auto Minify: Strips whitespace from server HTML, causing text node mismatches. Fix: disable HTML Auto Minify in Cloudflare dashboard → Speed → Optimization. [src7]
- Browser extensions injecting DOM nodes: Extensions like Grammarly, ad blockers, and
password managers add elements to the DOM. Fix: test in incognito; these are not your bugs — React 19
handles third-party
<script>and<link>tags gracefully. [src2, src8] - Styled-components without SSR config: Generates different class names on server vs
client without proper
ServerStyleSheetsetup. Fix: follow the styled-components SSR guide. [src7] - Using
useId()incorrectly across server/client trees: React'suseId()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 without mounted guard: The popular
next-themeslibrary causes hydration mismatch if you render theme-dependent content without checkingmountedstate first. Fix: use themountedcheck fromuseTheme()before rendering theme-specific UI. [src7] - iOS Safari auto-detection of phone numbers, dates, and emails: iOS wraps detected
patterns in
<a>tags after the page loads, creating extra anchor elements not present in server HTML. Fix: add<meta name="format-detection" content="telephone=no, date=no, email=no, address=no">to your document head. [src2]
Diagnostic Commands
# 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 History & Compatibility
| 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 |
When to Use / When Not to Use
| 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 |
Important Caveats
- React 19 improved hydration resilience: it now gracefully handles third-party
<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] suppressHydrationWarningonly works one level deep — it suppresses the warning for that element's attributes and text content, not for its children. [src5]- In development mode, React 18+ logs hydration mismatches as errors (not warnings). In production, React silently falls back to client-side rendering, which hurts performance — the mismatch is hidden but the penalty is real. [src1]
- Next.js App Router (
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] - The
useEffectpattern introduces a flash of default content (FODC). For theme switching, consider using a blocking<script>in<head>to set thedata-themeattribute before React hydrates. [src4] - React 19 introduces
onRecoverableErrorcallback onhydrateRoot— use this to log hydration recovery events to monitoring services without crashing the app. [src8]