It was 11pm, an hour before a client demo, and the console was screaming red. The error message sprawled across my terminal in that unmistakable wall of text:
Error: Hydration failed because the initial UI does not match what was rendered on the server.
The page looked fine visually, but React had fully remounted the entire component tree, tanking performance and leaving a nasty flash on load. The demo was the next morning. I had no idea what was causing it.
If you've seen that error, this guide is for you. By the end, you'll understand exactly what hydration is and why it breaks, recognize every common pattern that causes it, and have working code for each fix. No more guessing.
What Is a Next.js Hydration Error?
Next.js renders your page on the server first, producing raw HTML that the browser can display immediately. Then React loads in the browser and "hydrates" that HTML — it attaches event listeners, wires up state, and takes over interactivity. This two-phase process is what makes Next.js fast.
Hydration only works under one condition: the HTML React produces in the browser must be byte-for-byte identical to the HTML the server produced. React doesn't re-render from scratch — it walks the existing DOM and matches it against what it expects. If anything differs, it throws a hydration error and falls back to a full client-side render.
The full error message in Next.js typically looks like this:
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <div> in <div>.
Or in newer Next.js versions:
Unhandled Runtime Error
Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
Both mean the same thing: server HTML and client HTML don't match. Let's look at every common reason why.
Why Do Dynamic Values Like Date.now() Break Hydration?
The most common hydration error. Any value that differs between server and client — generated at render time — will break hydration.
Classic offenders: Date.now(), Math.random(), new Date(), and typeof window.
The server runs in Node.js. The client runs in the browser. They execute at different times. A timestamp or random number generated during server render will never match the one generated milliseconds later during client hydration.
The broken version:
// ❌ Date.now() produces different values on server and client
export default function LastUpdated() {
return (
<p>Page loaded at: {new Date(Date.now()).toLocaleTimeString()}</p>
);
}
// ❌ Math.random() is different on every call
export default function RandomId() {
const id = Math.random().toString(36).slice(2);
return <div id={id}>Some content</div>;
}
// ❌ typeof window is 'undefined' on server, 'object' on client
export default function PlatformBadge() {
return (
<span>{typeof window !== "undefined" ? "Browser" : "Server"}</span>
);
}
The fix: defer to useEffect
Anything that should differ between server and client must be calculated after hydration — inside useEffect, which only runs in the browser.
// ✅ Calculate client-only value after hydration
"use client";
import { useState, useEffect } from "react";
export default function LastUpdated() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
if (time === null) return <p>Loading...</p>;
return <p>Page loaded at: {time}</p>;
}
// ✅ Generate stable IDs with useId (React 18+)
"use client";
import { useId } from "react";
export default function StableId() {
const id = useId();
return <div id={id}>Some content</div>;
}
// ✅ Client-only detection that doesn't break hydration
"use client";
import { useState, useEffect } from "react";
export default function PlatformBadge() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
// Render the same thing on server and during hydration
if (!isClient) return <span>Loading...</span>;
return <span>Browser</span>;
}
The key insight: on the initial render, return something that matches what the server would produce. Only after useEffect fires — safely past hydration — update to client-specific content.
Can Browser Extensions Cause Hydration Errors?
This one is sneaky because it's not your code at all — it's a browser extension injecting elements into the DOM before React hydrates.
Extensions like ColorZilla, Grammarly, password managers, and translation tools frequently inject <div>, <style>, or attribute nodes into the page. React encounters these unexpected nodes during hydration and panics.
The error often looks like:
Warning: Expected server HTML to contain a matching <div> in <body>.
Or the entire <html> or <body> element shows a mismatch.
You can reproduce it by opening your app in an incognito window. If the error disappears, a browser extension is the culprit.
The fix: suppressHydrationWarning
For elements that extensions commonly target — <html>, <body>, or wrapper <div>s — you can tell React to ignore mismatches on that specific element:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>
{children}
</body>
</html>
);
}
For content inside your components that legitimately differs between server and client (locale-formatted dates, user-specific data), the useEffect pattern from Pattern 1 is the correct solution — not suppressHydrationWarning.
How Does Invalid HTML Nesting Cause Hydration Errors?
React replicates the browser's HTML parsing rules. When you write invalid HTML nesting — structure the browser's parser would auto-correct — the server and client end up with different DOM trees even though you wrote the same JSX.
The browser is forgiving in ways that create silent mismatches.
Common invalid nesting patterns:
// ❌ <p> cannot contain block-level elements
export default function Article() {
return (
<p>
Some text
<div>A block element inside a paragraph</div>
</p>
);
}
// ❌ <a> cannot be nested inside another <a>
export default function Navigation() {
return (
<a href="/home">
Home
<a href="/home/sub">Sub-page</a> {/* Invalid nesting */}
</a>
);
}
// ❌ <ul>/<ol> can only contain <li> as direct children
export default function List() {
return (
<ul>
<div> {/* Invalid — div directly in ul */}
<li>Item one</li>
<li>Item two</li>
</div>
</ul>
);
}
// ❌ <table> has strict content model requirements
export default function DataTable() {
return (
<table>
<tr> {/* tr must be inside thead/tbody/tfoot */}
<td>Cell</td>
</tr>
</table>
);
}
The fixes:
// ✅ Use <div> or <section> instead of <p> for block content
export default function Article() {
return (
<div>
Some text
<div>A block element, now valid</div>
</div>
);
}
// ✅ No nested anchors — use a button or a different structure
export default function Navigation() {
return (
<div>
<a href="/home">Home</a>
<a href="/home/sub">Sub-page</a>
</div>
);
}
// ✅ Correct table structure
export default function DataTable() {
return (
<table>
<tbody>
<tr>
<td>Cell</td>
</tr>
</tbody>
</table>
);
}
The W3C HTML validator is a good sanity check when you suspect nesting issues. Paste your rendered HTML and it will flag structural problems that cause hydration mismatches.
What Happens When You Use Browser APIs During Render?
window, document, localStorage, navigator, and all other browser-only APIs throw a ReferenceError when accessed on the server — because they don't exist in Node.js. When your component accesses them at render time, the server crashes and the page can't even hydrate.
The related hydration issue occurs when you guard these calls with typeof window !== 'undefined' — which evaluates differently on server (false) and client (true), producing different render output.
The broken version:
// ❌ window doesn't exist on the server
"use client";
export default function ThemeToggle() {
// This crashes during server render
const saved = localStorage.getItem("theme");
const [theme, setTheme] = useState(saved ?? "dark");
return (
<button onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}>
Current: {theme}
</button>
);
}
// ❌ Conditional render based on window check — server/client mismatch
"use client";
export default function WindowSize() {
const width = typeof window !== "undefined" ? window.innerWidth : 0;
return <p>Window width: {width}px</p>;
// Server renders: "Window width: 0px"
// Client renders: "Window width: 1440px"
// Hydration mismatch!
}
The fix: always use useEffect for browser API access
// ✅ Read localStorage after mount
"use client";
import { useState, useEffect } from "react";
export default function ThemeToggle() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
useEffect(() => {
// Safe: only runs in the browser, after hydration
const saved = localStorage.getItem("theme") as "dark" | "light" | null;
if (saved) setTheme(saved);
}, []);
const toggle = () => {
setTheme((t) => {
const next = t === "dark" ? "light" : "dark";
localStorage.setItem("theme", next);
return next;
});
};
return (
<button onClick={toggle}>
Current: {theme}
</button>
);
}
// ✅ Window dimensions with proper initialization
"use client";
import { useState, useEffect } from "react";
export default function WindowSize() {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
setWidth(window.innerWidth);
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Server and initial client render: shows nothing (or a skeleton)
if (width === null) return <p>Window width: —</p>;
return <p>Window width: {width}px</p>;
}
For components that are entirely browser-only and should never render on the server at all, use Next.js's dynamic with ssr: false:
// ✅ Skip SSR entirely for browser-only components
import dynamic from "next/dynamic";
const MapComponent = dynamic(() => import("./MapComponent"), {
ssr: false,
loading: () => <div>Loading map...</div>,
});
export default function Page() {
return (
<main>
<h1>Location</h1>
<MapComponent /> {/* Never renders on server */}
</main>
);
}
This is particularly useful for components that depend on window, WebGL, canvas, or other browser-only APIs where there's no meaningful fallback to render on the server.
How Do You Debug a Next.js Hydration Error?
Reading the error, using suppressHydrationWarning correctly, and narrowing down the source quickly.
Reading the Error Message
Next.js hydration errors have a specific structure once you know how to parse them:
Warning: Prop `className` did not match.
Server: "theme-dark"
Client: "theme-light"
This tells you the prop name (className), what the server produced (theme-dark), and what the client produced (theme-light). That's usually enough to find the culprit — something is calculating theme differently based on environment.
Warning: Expected server HTML to contain a matching <div> in <p>.
This tells you about invalid nesting: a <div> appeared inside a <p>, which the browser auto-corrected differently than the server's parser.
Error: Hydration failed because the initial UI does not match what was rendered on the server.
This is the general catch-all. Add --inspect to your Node.js process and check the full stack trace to find which component tree is responsible.
Binary Search the Component Tree
When you can't tell which component is causing the mismatch:
// Temporarily add suppressHydrationWarning to progressively narrow down
export default function SuspectLayout({ children }: { children: React.ReactNode }) {
return (
<div suppressHydrationWarning>
{children}
</div>
);
}
If adding it to a parent silences the error, the culprit is somewhere in that subtree. Remove it and add it deeper until you isolate the exact component.
Using suppressHydrationWarning Correctly
suppressHydrationWarning is legitimate for specific cases. Use it when:
- The content intentionally differs (timestamps that update, user-specific data)
- The element is targeted by browser extensions you can't control
- You have a confirmed third-party library causing a one-time mismatch
// ✅ Legitimate use: timestamp that's intentionally server/client different
export default function Footer() {
return (
<footer>
<time suppressHydrationWarning dateTime={new Date().toISOString()}>
{new Date().toLocaleDateString()}
</time>
</footer>
);
}
Do not use it as a blanket suppressant for errors you don't understand. The warning exists to surface real problems.
Next.js-Specific: Check the "use client" Boundary
In App Router, a common source of hydration errors is Server Components accidentally relying on client state, or Client Components not being marked properly:
// ❌ Server Component using client-only hook
// app/components/UserGreeting.tsx (no "use client")
import { useState } from "react"; // This will error at build time
export default function UserGreeting() {
const [name] = useState("Guest");
return <h1>Hello, {name}</h1>;
}
// ✅ Correct: mark as Client Component
"use client";
import { useState } from "react";
export default function UserGreeting() {
const [name] = useState("Guest");
return <h1>Hello, {name}</h1>;
}
The boundary matters: everything inside a Client Component subtree is hydrated. Everything in a Server Component renders once on the server and is static HTML on the client.
Wrapping Up
Hydration errors feel mysterious at first, but they all come from the same root cause: the server and client producing different HTML. Once you recognize that, every pattern becomes predictable.
Here's the complete reference:
| Pattern | Cause | Fix |
|---|---|---|
Date.now() / Math.random() at render | Different values each execution | Move to useEffect, use useId() for IDs |
typeof window conditional render | Returns different values server vs. client | Initialize as null, set in useEffect |
| Browser extension injection | Extension modifies DOM before hydration | suppressHydrationWarning on <html>/<body> |
| Invalid HTML nesting | Browser auto-corrects differently than server | Fix nesting: no <div> in <p>, no nested <a> |
localStorage / window at render | Browser APIs don't exist in Node.js | Move to useEffect or use dynamic({ ssr: false }) |
Missing "use client" | Hooks used in Server Component | Add "use client" directive |
The debugging workflow that saves the most time:
- Read the error message — it usually names the prop or element that mismatched
- Try incognito to rule out browser extensions
- Use
suppressHydrationWarningas a binary search tool to narrow down the subtree - Look for
typeof window,Date,Math.random(), andlocalStoragein the suspect component - Move any browser-specific logic into
useEffect
Once you've fixed a few of these, you develop an instinct for what will and won't cause mismatches. The server/client divide becomes a mental model you carry naturally — and the errors stop being surprises.