What the error means
The server rendered HTML. The browser received it and displayed it. React then ran the same components in the browser and compared its output to the HTML it was attaching to. They differed. React logs the mismatch and, in production, throws away the server HTML for that subtree and re-renders it client-side, which costs a flash, a layout shift and the time the server render was supposed to save.
React 19 reports the mismatch with a diff of the differing element, which makes finding it far easier than it used to be. The console message names the component stack and shows + client and - server lines. Start there.
Streaming changes the shape of the problem
With Suspense boundaries and streaming, the page arrives in pieces. The shell hydrates first; each boundary hydrates when its content streams in. Two consequences:
- A mismatch inside a boundary only re-renders that boundary, so the damage is contained, but the error can appear later than the initial load, which confuses timing-based reasoning.
- Content that depends on when it rendered (time, randomness) has more opportunities to differ, because the server rendered the boundary at one moment and the client hydrates it at another.
The diagnosis method is the same; the timing is the thing to keep in mind.
The six causes, in order of frequency
1. Time and locale
new Date().toLocaleDateString(), Intl.DateTimeFormat without an explicit locale and time zone, relative times like "3 minutes ago". The server is in UTC with a default locale; the browser is in the visitor's zone and locale. The strings differ.
Fix: format on the server with an explicit locale and time zone and pass the string down, or render the formatted value only after mount in a Client Component (with the raw ISO value as a <time dateTime> attribute for SSR). For "time ago", render the absolute time on the server and upgrade on the client.
2. Randomness and IDs
Math.random(), Date.now() for keys, uuid() in render, or a hand-rolled counter for element IDs. Different on every render.
Fix: useId() for accessibility IDs. Generate any random value once, on the server, and pass it as a prop, or generate it in a useEffect after mount. Never in render.
3. Browser-only APIs read during render
window.innerWidth, localStorage.getItem('theme'), navigator.language, matchMedia. On the server these are undefined and the code takes the fallback branch; on the client it takes the real branch.
Fix: read them in useEffect and store in state, rendering the fallback until then. For a theme, apply the class with a tiny inline script before React runs (the standard anti-flash pattern) and let React read the class rather than localStorage.
4. Invalid HTML nesting
<div> inside <p>, <a> inside <a>, <tr> directly inside <table> without <tbody>, <li> outside a list. The browser's parser corrects the HTML while parsing; React's client render produces the uncorrected tree. They disagree.
Fix: the console names the nesting. Fix the markup. Common in MDX content and in components that accept arbitrary children.
5. Conditional rendering on client-only state
{isMounted && <Widget />} where isMounted defaults to true, or {typeof window !== 'undefined' && ...} in render. The server renders one branch, the client the other.
Fix: make the initial client render match the server: default the state to the server's value and update in an effect. Or move the component behind next/dynamic with ssr: false if it genuinely cannot render on the server.
6. Browser extensions and third-party scripts
Ad blockers, translation extensions, password managers and Grammarly modify the DOM before React hydrates, injecting attributes or elements. The mismatch is real but not yours.
Fix: reproduce in a private window with extensions disabled. If it vanishes, it is an extension. For attributes on <html> or <body> that extensions add, suppressHydrationWarning on that element is legitimate. For elements injected into your content, there is little to do beyond making sure your own markup is clean, so the extension's changes are the only difference.
The method
- Read the React 19 diff in the console. It shows the element and the differing text or attribute. Nine times out of ten the cause is now obvious.
- Reproduce in a clean browser profile. Rules out extensions.
- Find the boundary. Which Suspense boundary contains the element? The component stack tells you. If none, it is the shell.
- Check the six causes against that component's render: any date, random, browser API, nesting, conditional, or third-party injection?
- Confirm on the server output.
curlthe page and inspect the HTML for that element. Compare to what the client would render. The difference is the bug. - Fix, then verify with the console clean on a cold load and on a client-side navigation.
What not to do
suppressHydrationWarning on everything. It hides the message, not the re-render, and the layout shift and wasted work remain. Use it only for the one element where a mismatch is genuinely unavoidable and harmless, such as a timestamp in a footer, and know that it applies one level deep.
'use client' on the whole tree. Turns the problem into a slower site.
dynamic(..., { ssr: false }) as a reflex. It is the right tool for a component that truly cannot render on the server (a map, a canvas). For a date, it throws away SSR to avoid formatting a string correctly.
Where this fits
Hydration errors on business sites are nearly always dates, theme toggles and MDX nesting, and they are found in the first performance pass of any Next.js build, because a boundary that re-renders on hydration is a boundary whose server render was wasted and whose layout shift shows up in Core Web Vitals.