How to Fix React 18 Hydration Error: Text Content Does Not Match Server-Rendered HTML
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke: A value rendered during SSR (e.g.,
Date.now(),window.*,localStorage, or any non-deterministic expression) differs from what React renders on the client during hydration, causing React 18 to throw and potentially bail out of hydration entirely. - How to fix it: Gate all client-only, non-deterministic values behind a
mountedstate flag set insideuseEffect, so the server and client initial render are identical. - Quick win: Use our Client-Side Sandbox below to auto-refactor this — paste your component and get the corrected code without sending your code to a third-party server.
The Incident (What Does the Error Mean?)
You hit this in your browser console or server logs:
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Text content did not match. Server: "Loading..." Client: "Welcome back, Alex"
at span
at MyComponent
React 18's concurrent hydration is stricter than React 17. When the server-rendered HTML string and the virtual DOM tree React constructs on the client do not match byte-for-byte, React throws a hydration error. In React 18 with hydrateRoot, this can cause React to re-render the entire subtree from scratch on the client, destroying your SSR performance budget and causing a visible layout flash (CLS spike).
Immediate consequence: Your Largest Contentful Paint (LCP) degrades, users see content flicker, and in worst cases React silently falls back to a full client-side render — your SSR is now doing nothing.
The Attack Vector / Blast Radius
This is not just a cosmetic warning. The cascading failure path:
- SSR renders the component with no access to
window,localStorage, or real-time values — outputs a static string. - Client hydration runs the same component, but now
useEffecthas fired or a browser API is accessible — the text node is different. - React 18 detects the mismatch during the reconciliation phase of
hydrateRoot. - React unmounts and remounts the affected subtree client-side. Your server HTML is thrown away.
- If this component is high in the tree (e.g., a layout wrapper reading
localStoragefor a theme), the entire page re-renders client-side. Your TTFB investment is wasted. - In Next.js App Router, this can trigger
Error: There was an error while hydratingwhich bubbles up to your nearest error boundary — potentially blanking a page section for users.
Common culprits:
new Date()/Date.now()called at render timetypeof window !== 'undefined'checks that return different values server vs. clientlocalStorage.getItem(...)read at render timeMath.random()or any non-seeded random value- Browser extension DOM injection (less fixable, use
suppressHydrationWarning) useId()misuse or manual ID generation
How to Fix It (The Solution)
Basic Fix — Mounted State Guard
Never render client-only values until after first mount. The server and client initial render must be identical.
- import React from 'react';
+ import React, { useState, useEffect } from 'react';
function UserGreeting() {
- const username = localStorage.getItem('username') || 'Guest';
- const time = new Date().toLocaleTimeString();
+ const [mounted, setMounted] = useState(false);
+ const [username, setUsername] = useState('Guest');
+ const [time, setTime] = useState('');
+
+ useEffect(() => {
+ setUsername(localStorage.getItem('username') || 'Guest');
+ setTime(new Date().toLocaleTimeString());
+ setMounted(true);
+ }, []);
return (
<span>
- Welcome back, {username}. Current time: {time}
+ {mounted ? `Welcome back, ${username}. Current time: ${time}` : 'Loading...'}
</span>
);
}
The rule: Whatever the server renders on first pass, the client must render the exact same markup on first pass. useEffect runs only on the client, after hydration is complete — it is the safe zone for browser APIs.
Enterprise Best Practice — Encapsulate with a useIsClient Hook + Suspense Boundary
For large codebases, a reusable hook prevents this class of bug systematically.
- // Ad-hoc mounted checks scattered across 40 components
- const [mounted, setMounted] = useState(false);
- useEffect(() => setMounted(true), []);
+ // hooks/useIsClient.ts — single source of truth
+ import { useState, useEffect } from 'react';
+ export function useIsClient(): boolean {
+ const [isClient, setIsClient] = useState(false);
+ useEffect(() => { setIsClient(true); }, []);
+ return isClient;
+ }
+ // Usage in any component
+ import { useIsClient } from '@/hooks/useIsClient';
+
function UserGreeting() {
+ const isClient = useIsClient();
+ const username = isClient ? localStorage.getItem('username') ?? 'Guest' : 'Guest';
return <span>Welcome back, {username}</span>;
}
For Next.js App Router, prefer dynamic() with ssr: false for entire components that are irredeemably client-only:
- import UserGreeting from './UserGreeting';
+ import dynamic from 'next/dynamic';
+ const UserGreeting = dynamic(() => import('./UserGreeting'), { ssr: false });
This tells the SSR pass to render nothing (or a skeleton) and lets the component hydrate freely on the client without a mismatch.
For browser extension noise (DOM mutations you don't control), scope suppressHydrationWarning surgically:
- <div>
+ <div suppressHydrationWarning>
{content}
</div>
⚠️ Do not use suppressHydrationWarning to paper over your own logic bugs. It silences the warning but does not prevent the client re-render. It is only appropriate for third-party DOM mutations.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of bug should never reach production. Enforce it at multiple gates:
1. ESLint Rule — eslint-plugin-react + custom rule
+ // .eslintrc.js
+ rules: {
+ 'no-restricted-globals': ['error', 'localStorage', 'sessionStorage', 'window', 'document'],
+ }
This forces engineers to access browser globals only inside useEffect or useIsClient-gated blocks.
2. Playwright / Cypress Hydration Smoke Test
Add a test that checks for hydration errors in the browser console on every SSR page:
+ // e2e/hydration.spec.ts
+ test('no hydration errors on homepage', async ({ page }) => {
+ const errors: string[] = [];
+ page.on('console', msg => {
+ if (msg.type() === 'error' && msg.text().includes('Hydration')) {
+ errors.push(msg.text());
+ }
+ });
+ await page.goto('/');
+ expect(errors).toHaveLength(0);
+ });
Run this in your CI pipeline on every PR targeting main.
3. Next.js / Vite Build-Time Detection
Enable React's strict hydration logging in development — it surfaces mismatches before they hit staging:
+ // next.config.js
+ const nextConfig = {
+ reactStrictMode: true, // Already enables double-invoke behavior
+ };
4. Storybook + @storybook/addon-storyshots SSR Snapshot Tests
Render every component in a Node.js environment via renderToString and snapshot the output. Any component that reads window at render time will throw immediately in this test — catching it at the component level before integration.