Fix: React Hydration Error — Text Content Does Not Match
Part of: React & Frontend Errors
Quick Answer
How to fix React hydration errors — server/client HTML mismatches, useEffect for client-only code, suppressHydrationWarning, dynamic content, and Next.js specific hydration issues.
The Error
React throws a hydration mismatch error in the browser console:
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 Next.js:
Error: Text content does not match server-rendered HTML.
See more info here: https://nextjs.org/docs/messages/react-hydration-errorOr a more specific mismatch:
Warning: Prop `className` did not match. Server: "dark" Client: "light"
Warning: Expected server HTML to contain a matching <p> in <body>.The page renders but the content flickers, or interactive elements don’t work correctly after the initial load.
Why This Happens
Hydration is the process where React attaches event listeners to server-rendered HTML. React expects the client-side render output to exactly match the server-rendered HTML. If they differ, React throws a hydration error and falls back to a full client-side re-render.
The strictness of this check catches people off guard. React doesn’t just compare the visible text — it compares the entire DOM tree, including element types, attributes, class names, and text content. A single extra space, a different CSS class, or a missing wrapper <div> is enough to trigger the error. In React 18, hydration mismatches are errors (not just warnings), and they cause React to discard the server-rendered HTML and re-render the entire subtree from scratch on the client. This re-render is expensive: it undoes the performance benefit of SSR and causes a visible flash of content.
The root cause is always the same: something produces different output on the server than on the client. The server renders at request time in a Node.js environment. The client hydrates in the browser, potentially seconds later, in a completely different environment with access to window, localStorage, the user’s timezone, installed browser extensions, and whatever DOM mutations third-party scripts have made in the meantime.
Common causes of mismatches:
- Browser-only APIs in render —
window,localStorage,navigator,documentdon’t exist on the server. Code that reads them returns different values server vs. client. - Random or time-dependent values —
Math.random(),Date.now(),new Date()produce different values on server and client. - Browser extensions — ad blockers, password managers, and translation extensions modify the DOM after server render but before hydration.
- Invalid HTML nesting —
<p>inside<p>,<div>inside<p>— browsers auto-correct invalid HTML differently than React expects. - Conditional rendering based on client state — checking
localStorage, cookies, or auth state that differs from the server’s assumption. - CSS-in-JS class names — some CSS-in-JS libraries generate non-deterministic class names.
- Third-party scripts modifying the DOM before React hydrates.
Diagnostic Timeline
The first instinct is to add suppressHydrationWarning to the offending element. That hides the warning but doesn’t fix the bug — it just tells React to ignore the mismatch, which means the server-rendered HTML and the client-rendered HTML stay out of sync. Walk through these steps to find and fix the actual cause.
Minute 0 — Read the DOM diff in the error. React 18’s error message shows the expected (server) and actual (client) HTML. Copy both and diff them. The difference tells you exactly which element or text node is mismatched. If the error says Server: "dark" Client: "light" on a className prop, the mismatch is in theme logic.
Minute 2 — Identify dynamic content. Search the component tree for anything that reads browser-only APIs: window, localStorage, sessionStorage, navigator, document.cookie, Date.now(), new Date(), Math.random(). Any of these produce different values on server and client. If you find one in a render path (not inside useEffect), that’s the cause.
Minute 4 — Check for browser extensions. Disable all browser extensions and reload. Grammarly injects <grammarly-extension> elements into the DOM. Google Translate rewrites text nodes. Password managers add attributes to form inputs. If the error disappears with extensions disabled, the mismatch is caused by an extension, not your code. You can’t fix it for affected users, but you can wrap sensitive areas in a client-only boundary.
Minute 6 — Validate HTML nesting. Paste the component’s rendered HTML into the W3C validator. Browsers silently fix invalid nesting (removing an inner <p> when it’s inside another <p>, for example), and the “fixed” DOM doesn’t match what React expects. Common offenders: <div> inside <p>, <a> inside <button>, arbitrary elements directly inside <table>.
Minute 8 — Check third-party scripts. If a chat widget, analytics snippet, or ad tag injects elements into the <body> before React hydrates, the DOM tree changes between server render and client hydration. Move these scripts to load after hydration using useEffect or Next.js <Script strategy="afterInteractive">.
Minute 10 — Reproduce in production vs development. React 18 in development mode gives detailed hydration diff output. Production mode silently falls back to client rendering. If you only see the error in production logs (via an error boundary), reproduce it locally by checking for timezone differences, locale differences, or auth state that the server doesn’t have.
Fix 1: Move Browser-Only Code to useEffect
Code that accesses browser APIs must run only on the client. useEffect runs only after the component mounts — never on the server:
// WRONG — window is undefined on server
function ThemeToggle() {
const [theme, setTheme] = useState(
window.localStorage.getItem('theme') || 'light' // ReferenceError on server
);
return <button>{theme}</button>;
}
// WRONG — initial value differs server/client
function ThemeToggle() {
const [theme, setTheme] = useState(
localStorage.getItem('theme') || 'light' // 'light' on server, stored value on client
);
// Hydration mismatch: server renders "light", client renders "dark"
return <button>{theme}</button>;
}// CORRECT — read browser APIs only after mount
function ThemeToggle() {
const [theme, setTheme] = useState('light'); // Server-safe default
useEffect(() => {
// Runs only on client, after hydration completes
const stored = localStorage.getItem('theme');
if (stored) setTheme(stored);
}, []);
return <button>{theme}</button>;
}Pattern — track whether component is mounted:
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null; // Server renders nothing — no mismatch
return <>{children}</>;
}
// Use for browser-only components
<ClientOnly>
<ThemeToggle />
<BrowserStorageDisplay />
</ClientOnly>Fix 2: Use dynamic() with ssr: false in Next.js
Next.js provides a built-in way to disable server-side rendering for specific components:
// next.js — disable SSR for a component
import dynamic from 'next/dynamic';
const ThemeToggle = dynamic(
() => import('./ThemeToggle'),
{ ssr: false } // Component only renders on client — no hydration mismatch
);
const Map = dynamic(
() => import('./Map'),
{
ssr: false,
loading: () => <div>Loading map...</div>, // Shown server-side and during client load
}
);
export default function Page() {
return (
<div>
<ThemeToggle /> {/* Client-only — no SSR */}
<Map /> {/* Client-only — no SSR */}
</div>
);
}ssr: false trade-off — the component doesn’t render on the server at all, so search engines can’t index its content. Use it only for truly browser-only components (maps, charts, browser storage readers).
Fix 3: Use suppressHydrationWarning for Intentional Differences
Some differences between server and client are intentional and harmless — formatted dates, user-specific greetings. Suppress the warning on specific elements:
// Suppress warning on a specific element
function Timestamp({ date }: { date: Date }) {
return (
<time
dateTime={date.toISOString()}
suppressHydrationWarning // ← Suppresses mismatch warning for this element only
>
{date.toLocaleString()} {/* Format differs server/client based on locale */}
</time>
);
}
// Only suppresses the warning on the element it's applied to — not its children
<div suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</div>Warning:
suppressHydrationWarninghides the warning but doesn’t fix the mismatch. React still re-renders the element on the client. Use it only when the difference is cosmetic (time formatting, locale) and not functional. Don’t use it to silence real bugs.
Fix 4: Fix HTML Nesting Errors
Browsers silently fix invalid HTML nesting, producing a different DOM than React expects:
// WRONG — <p> cannot contain block-level elements
// Browser removes the inner <div>, causing a mismatch
<p>
Some text
<div>Block element</div> {/* Invalid — <div> inside <p> */}
</p>
// WRONG — <ul> can only contain <li>
<ul>
<div>Not a list item</div> {/* Invalid */}
</ul>
// CORRECT
<div>
<p>Some text</p>
<div>Block element</div>
</div>Common invalid nesting patterns in React:
// WRONG — interactive elements can't be nested
<button>
<a href="/link">Click me</a> {/* <a> inside <button> — invalid */}
</button>
// CORRECT — use one or the other
<a href="/link" role="button" className="btn">Click me</a>
// WRONG — <table> elements must follow strict nesting rules
<table>
<div>Row wrapper</div> {/* Invalid */}
</table>
// CORRECT
<table>
<tbody>
<tr>
<td>Cell</td>
</tr>
</tbody>
</table>Find HTML validation errors — paste your HTML into the W3C validator or use browser DevTools → Elements → look for any auto-corrected nesting.
Fix 5: Handle Date and Random Values
Values that change between renders (time, random numbers) cause hydration mismatches:
// WRONG — time differs between server render and client hydration
function Greeting() {
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
return <p>{greeting}</p>;
// Server: "Good morning" (UTC 9:00)
// Client: "Good afternoon" (local 15:00) — mismatch
}
// CORRECT option 1 — render time-sensitive parts only on client
function Greeting() {
const [greeting, setGreeting] = useState('Hello'); // Generic server-safe default
useEffect(() => {
const hour = new Date().getHours();
setGreeting(hour < 12 ? 'Good morning' : 'Good afternoon');
}, []);
return <p>{greeting}</p>; // Server: "Hello", client: updates after mount
}
// CORRECT option 2 — pass the value as a prop from the server
// In Next.js App Router (Server Component):
export default function Page() {
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : 'Good afternoon';
return <Greeting initialGreeting={greeting} />;
// Server and client use the same greeting — no mismatch
}Random IDs — use a stable ID generator:
// WRONG — Math.random() differs server/client
function Item() {
const id = `item-${Math.random()}`; // Different every render
return <div id={id}>Content</div>;
}
// CORRECT — use React's useId() (React 18+)
import { useId } from 'react';
function Item() {
const id = useId(); // Stable, same on server and client
return <div id={id}>Content</div>;
}
// CORRECT — use a counter or UUID seeded from stable data
function Item({ index }: { index: number }) {
return <div id={`item-${index}`}>Content</div>;
}Fix 6: Handle Authentication and User State
Auth state often differs between server (not authenticated) and client (authenticated), causing hydration mismatches:
// WRONG — auth state differs server/client
function Header() {
const isLoggedIn = checkAuthCookie(); // Returns false on server, true on client
return (
<nav>
{isLoggedIn ? <UserMenu /> : <LoginButton />}
{/* Server renders LoginButton, client renders UserMenu — mismatch */}
</nav>
);
}Next.js App Router — pass auth state from server:
// app/layout.tsx (Server Component)
import { getServerSession } from 'next-auth';
export default async function Layout({ children }) {
const session = await getServerSession(); // Server-side auth check
return (
<html>
<body>
<Header session={session} /> {/* Same data on server and client */}
{children}
</body>
</html>
);
}
// Header.tsx (Client Component)
'use client';
function Header({ session }: { session: Session | null }) {
// Uses prop — no client-side auth check needed
return (
<nav>
{session ? <UserMenu user={session.user} /> : <LoginButton />}
</nav>
);
}Next.js Pages Router — use initial props:
export const getServerSideProps = async (context) => {
const session = await getSession(context);
return {
props: { initialAuth: !!session },
};
};
function Header({ initialAuth }: { initialAuth: boolean }) {
// Use initialAuth for first render — then client state takes over
const [isAuth, setIsAuth] = useState(initialAuth);
useEffect(() => {
// Sync with actual client-side auth state
checkClientAuth().then(setIsAuth);
}, []);
return <nav>{isAuth ? <UserMenu /> : <LoginButton />}</nav>;
}Fix 7: Debug Hydration Mismatches
React 18’s error message includes the expected vs. actual HTML. Use it to find the mismatch:
Warning: Expected server HTML to contain a matching <p> in <div>.
Hydration server mismatch:
Server: <div class="dark">...</div>
Client: <div class="light">...</div>Add data- attributes to narrow down the mismatch:
// Add a unique marker to each suspiciously dynamic section
<div data-section="theme-provider">
{children}
</div>
// If the error references this section, theme logic is causing the mismatchUse React DevTools — in development, React DevTools shows a warning icon on components with hydration mismatches. Clicking shows the specific prop or text content that differed.
Check for browser extensions — disable all extensions and reload. If the error disappears, an extension is modifying the DOM. Common culprits: Grammarly (adds attributes to inputs), Google Translate (rewrites text nodes), ad blockers (remove elements).
React 18 Strict Mode in development double-invokes renders — this can expose bugs that cause hydration mismatches by making them more visible:
// main.tsx
<React.StrictMode>
<App />
</React.StrictMode>
// Keep Strict Mode on — it helps catch bugs earlyStill Not Working?
Hydration error from third-party scripts — scripts that modify the DOM (chat widgets, analytics) before React hydrates cause mismatches. Load them asynchronously after hydration:
// Load third-party scripts after hydration
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://widget.example.com/chat.js';
script.async = true;
document.body.appendChild(script);
}, []);Next.js <Script> component with strategy="lazyOnload" or strategy="afterInteractive" ensures scripts load after hydration.
CSS-in-JS hydration — libraries like styled-components require server-side collection of styles to avoid mismatches. Follow each library’s SSR documentation.
Locale-dependent formatting — if formatting differs by server locale vs. client locale, pass locale explicitly:
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
// Specify locale explicitly — don't rely on system localeStreaming SSR with Suspense boundaries — in React 18 streaming, Suspense boundaries may resolve at different times on server and client. If a fallback shows on the server but the real content loads instantly on the client, the DOM trees diverge. Make sure Suspense fallbacks render the same initial structure on both sides, or wrap the dynamic portion in a client-only boundary.
Edge runtime vs Node.js runtime — in Next.js, the Edge runtime and Node.js runtime have different global objects and API availability. A component rendered on the Edge may produce different output than the same component rendered in Node.js (e.g., different Intl locale defaults, missing crypto methods). Verify which runtime your route uses and test accordingly.
Content from a CMS rendered with dangerouslySetInnerHTML — if the CMS returns slightly different HTML between the server fetch and the client fetch (e.g., different whitespace or entity encoding), the hydration check catches the difference. Fetch the content once on the server and pass it as a prop, rather than fetching on both server and client.
For related React issues, see Fix: React useEffect Runs Twice, Fix: Next.js App Router Fetch Cache, Fix: Next.js Hydration Failed, and Fix: React Strict Mode Double Render.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: nuqs Not Working — URL State Not Syncing, Type Errors, or Server Component Issues
How to fix nuqs URL search params state management — useQueryState and useQueryStates setup, parsers, server-side access, shallow routing, history mode, and Next.js App Router integration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.