Skip to content

Fix: React Hydration Error — Text Content Does Not Match

FixDevs · (Updated: )

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-error

Or 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 renderwindow, localStorage, navigator, document don’t exist on the server. Code that reads them returns different values server vs. client.
  • Random or time-dependent valuesMath.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: suppressHydrationWarning hides 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 mismatch

Use 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 early

Still 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 locale

Streaming 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles