Fixing Hydration Mismatch Errors in Server-Rendered React Apps Caused by Client-Only Browser APIs
The server renders one thing, the browser renders another, and React throws a hydration warning — or silently discards the server markup and re-renders the whole tree on the client. The usual cause isn't a bug in React; it's code that assumes the browser exists during server rendering. Here's how to find it and fix it for good.
The Problem
A server-rendered React app (Next.js, Remix, or any custom SSR setup) logs a warning like Text content does not match server-rendered HTML or Hydration failed because the initial UI does not match what was rendered on the server. Sometimes it's just a console warning and the page still works; sometimes React gives up reconciling and throws away the server-rendered DOM entirely, causing a visible flash as it re-renders the whole page from scratch on the client.
The confusing part is that the component code looks correct — no obvious bug, no typo, nothing that should render differently. The mismatch isn't in the component logic; it's in an assumption the component makes about its environment.
Why It Happens
Client-only globals referenced during the render itself
Code like const width = window.innerWidth or const isDark = localStorage.getItem("theme") === "dark" evaluated directly in a component's render path works fine on the client but throws (or returns undefined, silently changing output) on the server, where window and localStorage don't exist. The server renders one output, the client's first render produces another, and React's hydration diff catches the difference.
Non-deterministic values computed at render time
Math.random(), Date.now(), or auto-incrementing IDs generated fresh on each render will produce a different value on the server than on the client's hydration pass, even though both environments are otherwise identical — because they're not derived from any shared input, just called twice, once per environment.
Locale- or timezone-dependent formatting that differs between server and client
new Date(x).toLocaleDateString() and similar formatting calls depend on the runtime's locale and timezone configuration. A server process running in UTC and a browser running in the visitor's local timezone can legitimately format the same timestamp as different strings, which is a real content difference, not a false positive.
The Fix
1. Move browser-only reads out of the render path and into an effect
function Widget() {
const [width, setWidth] = useState(null);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
if (width === null) return null; // matches server output exactly
return {width}px wide;
}
The server and the client's first render both produce the same output (nothing, or a stable placeholder) because neither one touches window during render. The real value is read only after mount, inside useEffect, which never runs on the server.
2. Gate genuinely client-only UI behind a "mounted" flag, not an environment check
A common mistake is guarding with typeof window !== "undefined" directly in the render output — that still produces different markup between the server pass and the client's first pass, because the client's first render also happens before hydration settles. Use a state flag that only flips after mount instead:
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? <>{children} : null;
}
Both the server render and the client's initial hydration render see mounted === false and agree on the output; the client-only content appears in the second render pass, after hydration has already succeeded cleanly.
3. Reserve suppressHydrationWarning for content that's expected to differ, not as a general fix
For genuinely unavoidable differences — a relative timestamp like "3 minutes ago" that's supposed to keep changing — suppressHydrationWarning on that specific element tells React not to flag the mismatch, without disabling hydration checks anywhere else. It should be scoped to one element, never applied broadly to silence unrelated warnings.
4. Make date and number formatting deterministic between server and client
Pass an explicit locale and timezone to formatting calls instead of relying on the runtime's default (toLocaleDateString("en-US", { timeZone: "UTC" })), or format the date identically on both sides using a fixed-format library call rather than the environment-dependent default. If the display genuinely needs the visitor's local timezone, compute that value client-side only, using the mounted-flag pattern above.
Why This Works
Every one of these fixes addresses the same root cause: hydration only succeeds when the server's render output and the client's very first render output are identical. Moving browser reads into useEffect guarantees neither environment touches browser-only state during render. The mounted-flag pattern gives both passes an identical starting state before revealing client-only content. Deterministic formatting removes the last source of environment-dependent output. None of it works around React's hydration check — it satisfies the actual requirement the check exists to enforce.
Conclusion
Hydration mismatches are almost never a React bug — they're a symptom of component code reading browser-only or non-deterministic state during the render itself, before it's safe to assume the two environments agree. Move those reads into effects, gate client-only markup behind a mounted flag rather than an inline environment check, and keep date/locale formatting explicit and deterministic on both sides.
