DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Debugging Hydration Mismatches in the Next.js App Router

Headline: A hydration mismatch happens when the HTML your Next.js server renders does not match what React produces on the first client render, so React 19 throws away that subtree and re-renders it in the browser — you see a flash and a console diff. The fix is almost never suppressHydrationWarning; it is making the first client render deterministic.

Key takeaways

  • A hydration mismatch is when the HTML Next.js renders on the server does not match React's first client render, so React 19 discards that subtree and re-renders it in the browser.
  • The usual causes are non-deterministic values (Date.now(), Math.random()), browser-only APIs (localStorage, window) read during render, invalid HTML nesting, and DOM changes injected by browser extensions.
  • suppressHydrationWarning is not a general fix; it silences the warning for one element whose text is legitimately allowed to differ, like a timestamp.
  • Render browser-only values with a mounted flag and useEffect, or with useSyncExternalStore and a server snapshot, so both sides agree.
  • A hydration mismatch appears only on the first server-rendered load, never on client-side navigation in the App Router.

I lost the better part of an afternoon to a single flash on refresh: a timestamp column flickered and the theme toggle blinked to its default for one frame, and the console held one red line — a hydration mismatch. Once it clicked that hydration is React re-attaching to server HTML rather than re-rendering it, the whole family of bugs turned mechanical. These are my field notes from the App Router.

What is a hydration mismatch, and why does it break the page?

A hydration mismatch is when the HTML Next.js renders on the server does not match the output of React's first render in the browser. During hydration, React walks the existing server DOM and attaches event listeners to it instead of recreating it, assuming the tree it computes matches the DOM already on the page. When the two differ, React 19 discards the mismatched subtree and re-renders it from scratch on the client. That re-render is the flash you see, and it forfeits the server-rendering benefit for that subtree. In development, React 19 prints a readable diff naming the element and the differing text, a large improvement over the old generic message.

Why do Date.now() and localStorage cause a hydration error?

Reading a non-deterministic or browser-only value during render is the most common cause. Date.now(), Math.random(), and new Date().toLocaleString() produce different output on the server than in the browser, because the server ran a moment earlier and in a different timezone and locale. Reading localStorage, window, or navigator during render is worse: the server has no such object, so it renders one branch and the client renders another.

// server and client compute different text -> mismatch
function LastSeen() {
  return <span>{new Date().toLocaleTimeString()}</span>;
}
Enter fullscreen mode Exit fullscreen mode

The server stamps the time it rendered; the browser stamps a later time in the user's locale. React sees two different strings and throws.

When should I use suppressHydrationWarning?

Reach for suppressHydrationWarning only when the text of a single element is legitimately allowed to differ between server and client; a timestamp is the canonical case. It suppresses the warning for that one element, one level deep. It does not silence its children, and it is not a blanket fix.

<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>
Enter fullscreen mode Exit fullscreen mode

Using it to hide a real logic mismatch just trades a visible error for an invisible one. If you find yourself adding it to more than a couple of leaf nodes, the real problem is elsewhere.

How do I render a client-only value without a mismatch?

Render a stable placeholder on the server and swap in the real value after mount. For a theme, a viewport width, or a localStorage preference, the two-pass pattern uses a mounted flag:

function Theme() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return <ThemeSkeleton />; // same markup on server + first client render
  return <ThemeToggle value={localStorage.getItem('theme')} />;
}
Enter fullscreen mode Exit fullscreen mode

Both the server and the first client render return the skeleton, so they match; the real value appears on the second render. For external browser state, useSyncExternalStore is cleaner because its third argument is a server snapshot:

const isWide = useSyncExternalStore(
  subscribe,
  () => window.matchMedia('(min-width: 1024px)').matches, // client
  () => false                                            // server snapshot
);
Enter fullscreen mode Exit fullscreen mode

Supplying that server value is what keeps hydration consistent. For a component that can never render on the server, next/dynamic with ssr: false skips server rendering entirely.

Why do browser extensions cause hydration errors on <body>?

Browser extensions cause hydration errors by mutating the DOM before React hydrates. Grammarly injects data-gramm attributes, password managers add wrappers, and dark-mode extensions rewrite inline styles on <html> and <body>. The server sent clean markup; by the time React hydrates, the DOM carries extra attributes, so they mismatch. Because you cannot control the user's extensions, the accepted fix is suppressHydrationWarning on the <html> and <body> elements in your root layout, which the Next.js documentation recommends. It is safe there because those elements carry no dynamic text of your own to protect.

How do I fix invalid HTML nesting mismatches?

Invalid HTML nesting causes mismatches that look mysterious because your JSX seems fine. The browser silently repairs illegal nesting by moving or closing tags, so the DOM it built no longer matches the tree React expects, and React reports a mismatch on an element you never touched.

Invalid nesting Why the browser rewrites it Fix
<div> inside <p> the <p> auto-closes before a block element use a <div> or <span> wrapper
<p> inside <p> a nested <p> is not allowed, so the outer closes early flatten to a single <p>
<a> inside <a> interactive elements cannot nest restructure so links are siblings
<table> without <tbody> the browser inserts a <tbody> for you add the <tbody> yourself

The fix is always to make the JSX produce valid HTML. React 19's diff usually points at the reparented node, which is the fastest way to find it.

FAQ

Q: What is the difference between a hydration mismatch and a rendering error?
A: A hydration mismatch is specifically the server HTML disagreeing with the first client render, and it appears only on initial load, never on client-side navigation. A rendering error is any exception thrown while rendering, at any time.

Q: Does suppressHydrationWarning fix the flash?
A: No. It silences the console warning for one element, but React still re-renders the mismatched content on the client. Use it only when the difference is intentional, like a timestamp.

Q: Why does the error only happen on refresh and not when I navigate?
A: Hydration runs once, on the first server-rendered load. Client-side navigation in the App Router renders entirely in the browser, so there is no server HTML to mismatch against.

Q: Can useEffect cause a hydration mismatch?
A: No. useEffect runs after hydration, so state you set inside it cannot affect the first render React compares against the server HTML. That is why the mounted-flag pattern is safe.

Q: How do I find which component is mismatching?
A: Read the React 19 development console diff, which names the element and shows the differing text. If you suspect an extension, reproduce in an incognito window with extensions disabled.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)