Debugging Hydration Mismatches in React and Next.js

In Brief
A hydration mismatch is where the server‑rendered HTML and the first client render don't match. To resolve, we need to start with values that change between environments: browser‑only APIs, time, randomness, locale formatting, conditional user state, different server and client inputs, and invalid HTML shape. A boring first render is usually easier to hydrate correctly.
Hydration bugs are frustrating because the page often looks almost right.
The server sends useful HTML, the browser starts showing it, and then React arrives to attach behaviour to that markup. When the first client render does not match what the server already produced, React has a problem. Sometimes that means a warning in the console. Sometimes it means React throws the server markup away and rebuilds the subtree. Sometimes it means the page flickers, event handlers attach late, or the UI looks subtly wrong rather than obviously broken.
That is why hydration issues can waste so much time. The visible symptom often shows up in one place, but the real cause usually lives earlier in the render path where server and client inputs stopped being deterministic.
What Hydration is Actually Trying to Do
Hydration is not the browser "running React for the first time". It is React taking over markup that already exists in the document and expecting that its first client render will describe the same structure and text.
That expectation is stricter than many teams realise.
If the server rendered:
<p>Viewport: desktop</p>but the first client render says:
<p>Viewport: mobile</p>React now has two competing truths about the same node. That is the basic shape of a hydration mismatch.
The important consequence is simple: anything that affects rendered output must either be available to both environments in the same form, or be delayed until after hydration completes. The component snippets below use TypeScript and JSX.
The Common Causes are Usually Boring
Hydration bugs rarely come from deep React magic. They usually come from ordinary code that became non‑deterministic once server rendering entered the picture.
Browser‑Only Values in Render
This is the classic one:
const ViewportLabel = (): JSX.Element => {
const isNarrow = window.innerWidth < 768;
return <p>Viewport: {isNarrow ? 'mobile' : 'desktop'}</p>;
};On the server, window does not exist. Even if the code is wrapped in a defensive check, the render can still drift:
const ViewportLabel = (): JSX.Element => {
const isNarrow =
typeof window !== 'undefined' && window.innerWidth < 768;
return <p>Viewport: {isNarrow ? 'mobile' : 'desktop'}</p>;
};Now the server always renders desktop, whilst the client may immediately render mobile. That is still a mismatch.
The fix is to render a stable initial value and move the browser‑dependent branch into an effect. In the Next.js App Router, this belongs in a Client Component:
'use client';
import { useEffect, useState } from 'react';
const ViewportLabel = (): JSX.Element => {
const [isNarrow, setIsNarrow] = useState<boolean | null>(null);
useEffect(() => {
setIsNarrow(window.innerWidth < 768);
}, []);
if (isNarrow === null) {
return <p>Viewport: loading...</p>;
}
return <p>Viewport: {isNarrow ? 'mobile' : 'desktop'}</p>;
};
export default ViewportLabel;The placeholder is not glamorous, but it is honest. Both environments agree on the initial render, and the client‑specific value appears after hydration.
Time, Randomness, and Locale Formatting
Rendering new Date() or Math.random() directly into markup is effectively asking for a mismatch.
This is a frequent offender:
const PublishedTime = ({
publishedAt
}: {
publishedAt: string;
}): JSX.Element => {
return <time>{new Date(publishedAt).toLocaleString()}</time>;
};If the server and browser have different locales, time zones, or formatting behaviour, the strings can differ even though the underlying timestamp is the same.
The stronger pattern is to choose one owner for the formatted value:
- format it on the server and pass the formatted string as a prop
- format it on the client after mount if the browser locale genuinely matters
- or render a stable machine‑readable value first and progressively enhance it later
The same rule applies to IDs or keys. If a value affects markup, it has to be deterministic across the server render and the first client render.
Conditional Markup Around User or Environment State
Authentication state, feature flags, A/B buckets, media queries, and consent state can all produce mismatches when the server and client do not have the same input at render time.
Suppose the server does not know whether the user has already opened a client‑side preferences panel:
const PreferencesPanel = (): JSX.Element => {
const isOpen = localStorage.getItem('preferences-open') === 'true';
return isOpen ? <aside>Preferences</aside> : <button>Open settings</button>;
};Read localStorage after mount and update state from the result, keeping the initial server and client output the same. Moving the read into a useState initialiser would still run it during rendering.
Invalid or Unstable HTML Shape
Hydration also fails when the DOM the browser builds from the HTML does not match the element tree React thinks it rendered.
Invalid nesting is a common example. A div inside a p, or conditionally switching between text nodes and elements in awkward ways, can produce a browser DOM that no longer lines up with React's assumptions.
Third‑party components can cause the same problem if they mutate markup during render or depend on browser globals too early.
A Good Debugging Sequence is More Valuable than a Clever Fix
Teams often jump straight to dynamic(() => import(...), { ssr: false }) because it removes the warning quickly. That can be acceptable for genuinely client‑only widgets, but it is a poor first move because it hides the root cause.
A more reliable debugging sequence looks like this:
- Use
next devfor detailed development diagnostics. Also reproduce the problem withnext buildandnext startto check production behaviour; production warnings are not generally more detailed. - Find the first mismatched node, not the whole page. The console warning usually points at the earliest divergence.
- Compare the server inputs with the first client inputs. Dates, locale,
window, cookies, query parameters, feature flags, and auth state are the usual suspects. - Remove non‑determinism from render. Anything that depends on browser‑only state, random values, or current time should move out of the initial render path.
- Check third‑party components and wrappers. If the mismatch starts only when a particular widget is present, isolate that subtree.
The real aim is not merely to silence the warning. It is to restore a render path that is explainable.
Next.js Gives Us a Few Honest Escape Hatches
Sometimes the correct answer really is "do not server render this".
If a component only makes sense in the browser, such as a canvas‑heavy visualiser or a widget built around browser APIs, loading it client‑side can be reasonable:
'use client';
import dynamic from 'next/dynamic';
const ClientOnlyChart = dynamic(
() => import('@/components/ClientOnlyChart'),
{
ssr: false
}
);
export default ClientOnlyChart;In the App Router, the dynamic call with ssr: false must also live in a Client Component; it is not supported in a Server Component. Use this for a widget that really needs the browser, after checking whether a stable initial render would solve the problem.
Likewise, suppressHydrationWarning has a place for tiny, deliberately unstable values, but it should feel like a scalpel rather than a default setting. If the UI is materially different between server and client, suppressing the warning does not make the contract healthy.
Keep the First Render Boring
The easiest way to avoid hydration pain is to make the first render deliberately boring.
Render from props, serialised data, and other values both environments can see in the same form. Defer browser‑only work until after mount. Keep formatting rules explicit. Treat third‑party components with suspicion until you know they are SSR‑safe.
That sounds conservative because it is. Hydration succeeds when the first render is predictable, not when it is ambitious.
Useful References
Hydration bugs stop feeling mysterious once we stop treating them as mysterious. Server HTML and client render have to agree on the initial story. When they do not, React is not being fussy. It is telling us the render path is trying to describe two different realities at once.
That is usually the right level to debug the problem: not "which warning string did React print", but "why did the application believe two different things during the same first paint".