gotchajavascriptreactMajorverified
Fix: React hydration mismatch errors
Viewed 9 times
React 18+, Next.js 13+, Remix 1+
server-side-renderingclient-side-renderinguseEffectuseStatedynamic-importsuppressHydrationWarninguseIdstreaming-ssrrenderToPipeableStream
browsernodejsssr
Error Messages
Problem
React throws 'Hydration failed because the initial UI does not match what was rendered on the server' or 'Text content does not match. Server: X Client: Y' when using SSR frameworks (Next.js, Remix, Gatsby). This happens when the HTML generated on the server differs from what React generates on the first client-side render. Common triggers: (1) Using Date.now() or new Date() in render — different timestamps on server vs client. (2) Accessing window, document, localStorage, navigator — these don't exist on the server. (3) Conditional rendering based on browser state (window.innerWidth, matchMedia). (4) Random IDs or Math.random() in render output. (5) Browser extensions that modify the DOM before React hydrates. (6) Using typeof window !== 'undefined' incorrectly in render. (7) Different locale/timezone between server and client.
Solution
Multiple strategies depending on the cause:
function Component() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className='skeleton' />;
return <div>Window width: {window.innerWidth}</div>;
}
import dynamic from 'next/dynamic';
const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false });
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
function Input({ label }) {
const id = useId();
return <><label htmlFor={id}>{label}</label><input id={id} /></>;
}
// WRONG (runs during render, causes mismatch):
const isClient = typeof window !== 'undefined';
return isClient ? <ClientComponent /> : null;
// RIGHT (defers to after hydration):
const [isClient, setIsClient] = useState(false);
useEffect(() => setIsClient(true), []);
// In development, React logs the mismatched content
// Binary search: comment out half the component to isolate the culprit
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://third-party.com/widget.js';
document.body.appendChild(script);
}, []);
- CLIENT-ONLY CODE — Use useEffect (runs only on client):
function Component() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className='skeleton' />;
return <div>Window width: {window.innerWidth}</div>;
}
- DYNAMIC IMPORT with ssr: false (Next.js):
import dynamic from 'next/dynamic';
const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false });
- SUPPRESSHYDRATIONWARNING for intentional mismatches:
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
- CONSISTENT IDs — Use useId() hook (React 18+):
function Input({ label }) {
const id = useId();
return <><label htmlFor={id}>{label}</label><input id={id} /></>;
}
- ENVIRONMENT CHECK — Correct pattern:
// WRONG (runs during render, causes mismatch):
const isClient = typeof window !== 'undefined';
return isClient ? <ClientComponent /> : null;
// RIGHT (defers to after hydration):
const [isClient, setIsClient] = useState(false);
useEffect(() => setIsClient(true), []);
- DEBUGGING — Find the exact mismatch:
// In development, React logs the mismatched content
// Binary search: comment out half the component to isolate the culprit
- THIRD-PARTY SCRIPTS — Load after hydration:
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://third-party.com/widget.js';
document.body.appendChild(script);
}, []);
Why
React SSR hydration works by comparing the server-rendered HTML (already in the DOM) with what React would render on the client. If they match, React 'adopts' the existing DOM nodes (fast). If they don't match, React has to throw away the server HTML and re-render from scratch (slow, causes flash of content, breaks SEO benefits of SSR). The comparison is strict — even whitespace differences or attribute order can trigger a mismatch. The root cause is always: something in your render path produces different output on server vs client.
Gotchas
- useEffect runs ONLY on the client — this is by design and is the primary escape hatch for browser-only code
- useState with a function initializer runs during render (both server and client) — don't put browser APIs there
- Browser extensions (ad blockers, Grammarly, password managers) inject DOM nodes that cause phantom hydration mismatches
- Next.js 13+ App Router handles some mismatches differently than Pages Router — check docs for your version
- suppressHydrationWarning only suppresses the warning, it doesn't fix the performance issue of re-rendering
- Timezone mismatches: server might be UTC while client is local time — use UTC everywhere or defer date formatting to useEffect
- CSS-in-JS libraries (styled-components, emotion) can cause hydration issues if server/client generate different class names
- React 18's streaming SSR (renderToPipeableStream) has different hydration behavior than renderToString
Context
Using React with SSR frameworks (Next.js, Remix, Gatsby)
Learned From
Common React SSR issue encountered across Next.js and Remix projects — the useState + useEffect pattern is the universal fix
Revisions (0)
No revisions yet.