Fix "Hydration Failed" Error in Next.js — Server vs Client Mismatch Explained
The Next.js hydration error is one of the most confusing bugs in React — the page renders fine on the server, but React throws a mismatch error in the browser. This guide explains exactly why it happens and covers every common cause with working fixes.
SSR
root cause: server/client HTML differs at render time
8+
common causes covered with before/after fixes
Next.js
13/14/15 App & Pages Router — same root causes
useEffect
key pattern for browser-only operations
The error you're seeing
Why Hydration Errors Happen
The core problem
Next.js renders your page HTML on the server first, then "hydrates" it in the browser — attaching React event listeners to the existing server-rendered HTML. If React generates different HTML during hydration than what the server sent, it throws a mismatch error. The server and browser must produce identical HTML from the same component tree.
Server renders your component tree to HTML
Next.js runs your React components on the server and produces a static HTML string. This string is sent to the browser as the initial page response — it's what users see before JavaScript loads.
HTML is sent to the browser
The browser receives the pre-rendered HTML and displays it immediately (fast initial paint). At this point, the page looks right but has no interactivity — buttons don't work, state doesn't update.
React hydrates — re-runs components in the browser
React runs your component tree again in the browser to attach event handlers. During this re-run, React generates its own virtual DOM and compares it to the server HTML.
React compares browser output to server HTML
If the component produces identical HTML — great, hydration succeeds silently. If the output differs by even one character, React throws the hydration error.
Mismatch → hydration error thrown
React throws "Hydration failed" and displays the error overlay in development. In production, React tries to recover by re-rendering the entire tree client-side, causing a visible flash of content and performance degradation.
Cause 1 — Using Browser-Only APIs During SSR
window, localStorage, navigator don't exist on the server
localStorage during render — server crash
// Reads localStorage during render — crashes on server
export default function ThemeButton() {
const theme = localStorage.getItem('theme') || 'light'; // ❌ no localStorage on server
return <button>{theme}</button>;
// Server: ReferenceError: localStorage is not defined
// Client: renders "dark" → mismatch
}localStorage inside useEffect — runs only in browser
// Only read localStorage after mount
'use client';
import { useState, useEffect } from 'react';
export default function ThemeButton() {
const [theme, setTheme] = useState('light'); // ✅ safe server default
useEffect(() => {
// useEffect only runs in the browser, never on the server
setTheme(localStorage.getItem('theme') || 'light');
}, []);
return <button>{theme}</button>;
// Server: renders "light" | Client: hydrates as "light", then updates after mount
}Cause 2 — Date/Time Differences
Date during SSR render — timezone mismatch
export default function Timestamp() {
return <p>Now: {new Date().toLocaleString()}</p>;
// ❌ Server: "1/15/2025, 10:00:00 AM" (UTC server time)
// Client: "1/15/2025, 2:00:00 PM" (user's local timezone)
// Different strings = hydration mismatch
}Date inside useEffect — client-only rendering
'use client';
import { useState, useEffect } from 'react';
export default function Timestamp() {
const [time, setTime] = useState<string>(''); // empty on server
useEffect(() => {
setTime(new Date().toLocaleString()); // ✅ only rendered client-side
}, []);
if (!time) return <p>Loading time...</p>; // server shows placeholder
return <p>Now: {time}</p>;
// Server: "Loading time..." | Browser: shows actual local time after mount
}Cause 3 — Invalid HTML Nesting
Invalid HTML — browser auto-corrects, React doesn't
// <div> inside <p> is invalid HTML — browsers auto-correct it differently than React expects
export default function Card() {
return (
<p>
<div>Content here</div> {/* ❌ block element inside inline element */}
</p>
);
// Browser moves <div> outside <p> automatically.
// React sees a different DOM structure than it rendered → hydration mismatch
}Valid HTML nesting — no auto-correction surprises
export default function Card() {
return (
<div> {/* ✅ div inside div is valid */}
<div>Content here</div>
</div>
);
}
// Other common invalid nesting to avoid:
// <ul> inside <p> → invalid
// <h1> inside <span> → invalid (block in inline)
// <table> without <tbody> → browsers add one automaticallyCause 4 — Browser Extensions
Test in incognito mode first
Cause 5 — Math.random() or Unique IDs
Math.random() during render — different every time
// Different random value on server vs client
export default function Badge() {
const id = Math.random(); // ❌ server gets 0.123, client gets 0.456
return <div id={`badge-${id}`}>New</div>;
// Different id values = mismatch
}useId() for stable server/client IDs
'use client';
import { useId } from 'react'; // React 18+ built-in stable ID generator
export default function Badge() {
const id = useId(); // ✅ same value on server and client — stable
return <div id={id}>New</div>;
}
// useId() generates deterministic IDs based on component position in the tree
// Same tree position = same ID on server and clientCause 6 — Conditional Rendering Based on window
Checking window during render — always null on server
// window is undefined on server → renders null → client renders something
export default function MobileMenu() {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return <nav>Mobile Nav</nav>;
}
return null;
// Server: window is undefined → always returns null
// Client: window.innerWidth may be 375 → returns <nav> → MISMATCH
}Check window after mount with useEffect
'use client';
import { useState, useEffect } from 'react';
export default function MobileMenu() {
const [isMobile, setIsMobile] = useState(false); // false on server
useEffect(() => {
setIsMobile(window.innerWidth < 768); // ✅ runs only in browser
// Optionally add a resize listener here
}, []);
if (!isMobile) return null; // server and initial client both return null
return <nav>Mobile Nav</nav>; // shown after mount on mobile
}Cause 7 — CSS-in-JS Class Name Mismatch
styled-components and emotion generate different class names
// app/layout.tsx — wrap with styled-components registry
'use client';
import { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export default function StyledComponentsRegistry({ children }) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
styledComponentsStyleSheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}
// Wrap your root layout:
// export default function RootLayout({ children }) {
// return <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
// }The suppressHydrationWarning and dynamic() Escape Hatches
| Item | Approach | When to Use + Trade-offs |
|---|---|---|
| suppressHydrationWarning | Add to a specific element | Use only for intentional mismatches (timestamps, user content). Hides the warning without fixing the root cause. |
| dynamic({ ssr: false }) | Skip SSR for entire component | Best for browser-only components (maps, charts, WebGL). Shows loading state on server, renders component client-side only. |
| useEffect + state | Defer browser value to after mount | Cleanest fix. Server renders with safe default. Browser updates state after hydration. Small visual flash. |
| isClient state | Boolean: false on server, true after mount | Simple pattern for "render X only on client" without dynamic import overhead. |
// Pattern 1: suppressHydrationWarning (for intentional, safe mismatches)
export default function LastSeen() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleString()}
</time>
);
// ⚠️ Use only when the mismatch is harmless — don't hide real bugs
}
// Pattern 2: dynamic() with ssr: false (for browser-only components)
import dynamic from 'next/dynamic';
const MapComponent = dynamic(() => import('./Map'), {
ssr: false,
loading: () => <div className="h-64 bg-gray-100 animate-pulse" />,
});
// Pattern 3: isClient pattern (lightweight alternative to dynamic)
'use client';
import { useState, useEffect } from 'react';
export function ClientOnly({ children, fallback = null }) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient ? children : fallback;
}
// Usage:
// <ClientOnly fallback={<Skeleton />}>
// <ComponentWithBrowserAPIs />
// </ClientOnly>