SSR Hydration Mismatch — 4 Patterns That Break and How to Defend
Four common hydration-mismatch triggers (localStorage / Date / Math.random / theme) in a Next.js static-export app, with concrete defense patterns and correct use of suppressHydrationWarning.
Opening — "Error: Hydration failed because..."
The night I finished setting up static export and shipped, the console lit up with dozens of red warnings:
Error: Hydration failed because the initial UI does not match what was
rendered on the server.
The server-rendered HTML and the DOM reconstructed on the client don't match. Page also flickers and re-renders.
Digging in revealed a handful of classic patterns every Next.js developer runs into. Here are the four and their defenses.
Type 1 — localStorage as Initial State
The offending code:
const [theme, setTheme] = useState(localStorage.getItem('theme') ?? 'light');
Why it breaks:
- Server: no
localStorage→ReferenceError, or'light' - Client: if the saved value is
'dark', renders'dark' - Different outputs → mismatch
The defense:
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') setTheme(saved);
}, []);
Match the initial value across both sides ('light'), inject the real value only after mount. First paint uses the default; the next tick updates.
⚠️
useMemotrap: writingconst x = useMemo(() => localStorage.get(...), [])caches the SSR-computednulland never re-runs after hydration. Permanently null. Always useuseEffect.
Type 2 — Date.now() / new Date()
The offending code:
const timeLabel = new Date().toLocaleTimeString();
return <span>{timeLabel}</span>;
Why it breaks:
- Server: the build-time timestamp
- Client: the visit-time timestamp
- Even a one-second difference is a mismatch
The defense:
const [time, setTime] = useState<string>('');
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
return <span>{time}</span>;
Initial value is an empty string. Real time shows up after client mount. "Time shows up one tick late" is an acceptable tradeoff.
If the time absolutely must be present at first paint, an alternative is to compute it on the server and pass it as a prop, with the client treating that as a reference point.
Type 3 — Math.random()
The offending code:
const id = Math.random().toString(36);
return <div id={id}>...</div>;
Why it breaks:
- Every call yields a different value → 100% mismatch between server and client
The defense:
// React 18+
const id = useId();
return <div id={id}>...</div>;
React's useId guarantees identical IDs on server and client. Perfect for aria-* associations.
If you genuinely need "a new random on every render", generate it inside useEffect and store in state.
Type 4 — Date-Seeded Deterministic Calculation
The offending code:
const today = new Date();
const seed = today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
const todayPick = pickBySeed(ITEMS, seed);
return <Card item={todayPick} />;
Patterns like "today's recommendation" that seed from a date — if the server's build date differs from the user's visit date, mismatch.
The defense:
const [todayPick, setTodayPick] = useState<Item | null>(null);
useEffect(() => {
const today = new Date();
const seed = today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
setTodayPick(pickBySeed(ITEMS, seed));
}, []);
if (!todayPick) return <CardSkeleton />; // identical on server and client
return <Card item={todayPick} />;
Render a placeholder (skeleton) that both server and client produce identically. The real value comes in after mount.
When to Use suppressHydrationWarning
React's escape hatch — "this element's children might mismatch, and that's OK":
<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>
When to use:
- Time displays like
<time>where a difference is intentional - Only for single text-node mismatches
When NOT to use:
- Trying to silence the whole subtree — this only covers one level
- As a dodge for the root cause
Usually the useEffect pattern comes first; suppressHydrationWarning is the last resort.
Debugging Tips
- Test with production build: dev mode is forgiving (warnings only). Production builds surface actual hydration failures
- React DevTools "Highlight updates when components render": visualize which components re-render
- console.error stack: "Hydration failed" errors point to which element differed. DOM inspector lets you verify the server HTML
Summary — One-Liner Recipes
| Type | Fix |
|---|---|
| localStorage initial value | Separate useState + useEffect |
| Date/time display | Empty initial + inject on mount |
| Math.random ID | useId() |
| Date-seeded decision | Placeholder + compute on mount |
Retrospective
Hydration mismatch feels punishing at first — "why would React put such an annoying constraint in?" But once you reframe it as "can the server and client make the same decision?", most cases resolve naturally.
One rule carries most of it: first render uses only values both server and client can compute; environment-dependent values get injected after mount. Once that habit sets in, mismatches almost stop happening.
Guestbook
Leave a short note about this post
Loading...