How to Fix React/Next.js Hydration Mismatch Errors

Type: Software Reference Confidence: 0.93 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

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

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

Related Units