← DEVLOG
Tech Notes2025.08.227 min read

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.

nextjsreactssrhydration

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 localStorageReferenceError, 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.

⚠️ useMemo trap: writing const x = useMemo(() => localStorage.get(...), []) caches the SSR-computed null and never re-runs after hydration. Permanently null. Always use useEffect.

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

  1. Test with production build: dev mode is forgiving (warnings only). Production builds surface actual hydration failures
  2. React DevTools "Highlight updates when components render": visualize which components re-render
  3. console.error stack: "Hydration failed" errors point to which element differed. DOM inspector lets you verify the server HTML

Summary — One-Liner Recipes

TypeFix
localStorage initial valueSeparate useState + useEffect
Date/time displayEmpty initial + inject on mount
Math.random IDuseId()
Date-seeded decisionPlaceholder + 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

0 / 140

Loading...