
How to Fix Hydration Errors in Next.js?
Few errors in Next.js are as common, or as confusing the first time you see them, as the hydration error. You load a page, the red overlay appears, and it tells you that the server-rendered HTML did not match what the client rendered. The page often still looks fine, which makes it tempting to ignore. But a hydration mismatch is a real bug: React throws away part of the server HTML and re-renders it in the browser, which causes visible flashes, lost scroll positions, slower interactivity, and sometimes content that search engines see but users do not.
The fix is almost never complicated once you know which category the bug falls into. Nearly every hydration error comes from one of a handful of causes: values that differ between server and browser, code that reads browser-only APIs during render, invalid HTML nesting, or third-party scripts and extensions that change the DOM before React gets to it.
This article covers what hydration is and why mismatches happen, how to read the error and find the component responsible, each common cause with a working fix, when suppressHydrationWarning is the right tool, and how to prevent these errors from coming back.
What Hydration Actually Is
When a user visits a page in Next.js, the server renders the components to HTML and sends it to the browser. The user sees content immediately, before any JavaScript has loaded. Then React loads in the browser, renders the same Client Components again, and attaches event listeners to the existing DOM instead of rebuilding it. That attach step is hydration.
For hydration to work, the browser render must produce exactly the same output as the server render. React walks the existing DOM and its own render output side by side. If an element, attribute, or text node differs, React cannot safely attach to the DOM it found. It reports a hydration error and, to recover, discards the server HTML up to the nearest Suspense or error boundary and renders that part of the tree from scratch on the client.
Two details are important in the App Router:
- Server Components do not hydrate as components. Their code never runs in the browser, so they cannot produce a different output there. But React still compares the DOM against the RSC payload they produced, so anything that modifies their DOM before hydration, such as a browser extension, can still trigger a mismatch.
- Client Components render twice on a first visit: once on the server to produce HTML, and once in the browser during hydration. Any value that differs between those two renders is a potential mismatch.
| Render | Where it runs | Has window? | Locale and time zone |
|---|---|---|---|
| Server render | Node.js on your server or build machine | No | The server's, usually UTC |
| Hydration | The user's browser | Yes | The user's |
Almost every hydration bug is a value that depends on something in the right column but not the left.
Reading the Error
In development, Next.js shows the error in an overlay along with a diff of the mismatched output. A typical message looks like this:
Hydration failed because the server rendered text didn't match the client.
As a result this tree will be regenerated on the client.
<LastUpdated>
<p>
+ 10/9/2026, 2:41:07 PM
- 10/9/2026, 12:41:07 PM
Lines marked with + are what the client rendered, and lines marked with - are what the server sent. The component names above the diff tell you where to look. In this example, a component called LastUpdated rendered a time string, and the server and browser are in different time zones.
If the overlay is not enough:
- Open the browser console. React logs the full component stack.
- View the page source (not the Elements panel) to see exactly what the server sent, before any JavaScript ran.
- Disable JavaScript in DevTools and reload. What you see is the server HTML. Compare it to the hydrated page.
- Try an incognito window with extensions disabled. If the error disappears, an extension is modifying the DOM.
Cause 1: Dates, Times, and Locale Formatting
Formatting dates is the single most common cause. toLocaleString(), toLocaleDateString(), and Intl.DateTimeFormat use the runtime's locale and time zone. Your server probably runs in UTC with an English locale; your users do not.
// app/components/last-updated.tsx (broken)
"use client";
export function LastUpdated({ iso }: { iso: string }) {
return <p>Last updated: {new Date(iso).toLocaleString()}</p>;
}
new Date() without an argument is worse: the server and browser render at different moments, so the output differs even with identical settings.
Fix A: Format on the server with an explicit time zone
If one fixed format is acceptable, make the output deterministic by specifying the locale and time zone, and render it in a Server Component:
// app/components/last-updated.tsx
const formatter = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
});
export function LastUpdated({ iso }: { iso: string }) {
return (
<p>
Last updated:{" "}
<time dateTime={iso}>{formatter.format(new Date(iso))} UTC</time>
</p>
);
}
Fix B: Render the local time only after hydration
If you need the user's local time, render a stable value first and switch to the local value after the component mounts. useSyncExternalStore with a different server snapshot is a clean way to express "this value only exists in the browser":
// app/components/local-time.tsx
"use client";
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
export function LocalTime({ iso }: { iso: string }) {
const isClient = useSyncExternalStore(
subscribe,
() => true, // client snapshot
() => false, // server snapshot
);
const date = new Date(iso);
return (
<time dateTime={iso}>
{isClient
? date.toLocaleString()
: date.toISOString().slice(0, 16).replace("T", " ") + " UTC"}
</time>
);
}
During hydration, React uses the server snapshot, so both renders output the UTC string. Immediately afterward, React re-renders with the client snapshot and shows local time. There is no mismatch, though the text does change once after load.
Fix C: Correct the text before first paint
To avoid even that brief change, the Next.js documentation describes an inline script pattern: render the server value, place a small script right after the element that rewrites it with the browser's locale during HTML parsing, and add suppressHydrationWarning to the element so React accepts the corrected DOM.
// app/events/[id]/page.tsx
import { getEvent } from "@/lib/events";
export default async function EventPage({ params }: PageProps<"/events/[id]">) {
const { id } = await params;
const event = await getEvent(id);
return (
<section>
<h1>{event.name}</h1>
<time id="event-date" dateTime={event.date} suppressHydrationWarning>
{new Date(event.date).toLocaleDateString("en-US", { timeZone: "UTC" })}
</time>
<script
dangerouslySetInnerHTML={{
__html: `document.getElementById("event-date").textContent=new Date(${JSON.stringify(event.date)}).toLocaleDateString()`,
}}
/>
</section>
);
}
This works on full page loads. For client-side navigations and Content Security Policy considerations, follow the reusable component approach in the official guide linked at the end of this article.
Cause 2: Browser-Only APIs During Render
Anything that reads window, document, localStorage, navigator, or matchMedia during render will either crash on the server or, if guarded with typeof window !== "undefined", produce different output on each side:
// app/components/welcome.tsx (broken)
"use client";
export function Welcome() {
const name =
typeof window !== "undefined" ? localStorage.getItem("name") : null;
return <p>{name ? `Welcome back, ${name}` : "Welcome"}</p>;
}
The server renders "Welcome". The browser renders "Welcome back, Sam". That is a mismatch by design. The typeof window check prevents the crash but creates the bug.
Fix: Read browser values after mount
Move the read into an effect so the first client render matches the server:
// app/components/welcome.tsx
"use client";
import { useEffect, useState } from "react";
export function Welcome() {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
setName(localStorage.getItem("name"));
}, []);
return <p>{name ? `Welcome back, ${name}` : "Welcome"}</p>;
}
For values that can change, such as viewport width or online status, useSyncExternalStore is better because it also subscribes to updates:
// app/hooks/use-media-query.ts
"use client";
import { useCallback, useSyncExternalStore } from "react";
export function useMediaQuery(query: string) {
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false, // assume "no match" on the server
);
}
Even better, avoid JavaScript for layout decisions entirely. A CSS media query or Tailwind's responsive prefixes show and hide content without any risk of mismatch.
Fix: Skip server rendering for one component
Some components cannot render on the server at all, such as a map widget or a chart library that touches window at import time. Load them with next/dynamic and ssr: false. In the App Router, this option is only allowed inside a Client Component:
// app/components/store-map.tsx
"use client";
import dynamic from "next/dynamic";
const Map = dynamic(() => import("./map-widget"), {
ssr: false,
loading: () => <div className="h-96 animate-pulse rounded bg-gray-100" />,
});
export function StoreMap({ lat, lng }: { lat: number; lng: number }) {
return <Map lat={lat} lng={lng} />;
}
Use this sparingly. The component's content will not be in the server HTML, so it is invisible to crawlers that do not run JavaScript and appears later for users. For more on code splitting with next/dynamic, see what dynamic imports are and how they are used in Next.js.
Cause 3: Random Values and Generated IDs
Math.random(), crypto.randomUUID(), and Date.now() produce different values in each render:
// broken
"use client";
export function EmailField() {
const id = `email-${Math.random().toString(36).slice(2)}`;
return (
<>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</>
);
}
Fix: Use useId for IDs
React's useId hook generates IDs that are identical on the server and client:
// app/components/email-field.tsx
"use client";
import { useId } from "react";
export function EmailField() {
const id = useId();
return (
<>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</>
);
}
For other random values, such as a shuffled list or a random testimonial, pick the value in a Server Component and pass it down as a prop, or choose it in an effect after mount.
Cause 4: Invalid HTML Nesting
Browsers repair invalid HTML while parsing it. If your component renders a div inside a p, the browser closes the paragraph early and moves the div out. React then finds a DOM tree that is different from the one it rendered.
Common offenders:
div,ul,table, or a heading inside ap- An
ainside anothera, often from a card that is a link containing other links - A
buttoninside anotherbutton trdirectly insidetablewithout atbody- Interactive elements inside a
labelthat is already associated with another control
// broken: a block element inside a paragraph
export function Notice({ children }: { children: React.ReactNode }) {
return (
<p className="notice">
<div className="notice-body">{children}</div>
</p>
);
}
// fixed
export function Notice({ children }: { children: React.ReactNode }) {
return (
<div className="notice">
<div className="notice-body">{children}</div>
</div>
);
}
This cause often hides in content. Markdown renderers wrap text in p tags, so a custom component that renders a div can end up inside a paragraph without any obvious nesting in your code. The Next.js error overlay calls out invalid nesting explicitly, with a message like "In HTML, div cannot be a descendant of p." Treat it as a hydration error even when the page looks correct.
Cause 5: Themes and Persisted Preferences
Dark mode is a classic case. The server does not know the user's saved theme, so it renders the default. If a Client Component reads localStorage during render to pick the theme class, the output differs. If it reads the theme in an effect, the user sees a flash of the wrong theme.
The reliable solution is a tiny blocking script in the head that sets the theme before first paint, plus suppressHydrationWarning on the html element because the script changes its attributes:
// app/layout.tsx
const themeScript = `(function(){try{var t=localStorage.getItem("theme");if(!t){t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}document.documentElement.dataset.theme=t}catch(e){}})()`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" data-theme="light" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
}
Libraries such as next-themes implement the same pattern for you. Either way, render theme differences with CSS selectors such as [data-theme="dark"] rather than by switching JSX on a JavaScript theme value, so the HTML is the same regardless of theme.
Cause 6: Browser Extensions and Third-Party Scripts
Some hydration errors are not your code. Password managers, translation tools, Grammarly, and ad blockers insert attributes or elements into the page before React hydrates. Typical signs are unfamiliar attributes in the diff, such as data-gr-ext-installed or cz-shortcut-listen, usually on html or body.
To confirm, reload in a private window with extensions disabled. If the error disappears, it is an extension. Adding suppressHydrationWarning to the body element silences attribute mismatches on that one element, which is reasonable here because you cannot control the extension.
Third-party scripts that modify the DOM, such as A/B testing tools or chat widgets, should be loaded with next/script using afterInteractive or lazyOnload so they run after hydration, not before. See how to load third-party scripts efficiently with next/script.
When to Use suppressHydrationWarning
suppressHydrationWarning tells React to accept the existing DOM for one element's text content and attributes, instead of reporting a mismatch. It has strict limits:
- It only applies one level deep. It does not silence mismatches in child elements.
- It only covers text and attributes, not structural differences such as an extra element.
- It keeps the server value. React does not patch the DOM with the client value, so if your client render differs, the user sees the server version until the next re-render.
Use it only for deliberate, understood differences: a timestamp corrected by an inline script, theme attributes on html, or extension attributes on body. Using it to make an unexplained error go away usually hides a real bug.
Preventing Hydration Errors
A few habits stop most of these bugs from being written in the first place:
- Keep components on the server by default. Server Components cannot mismatch on their own output. The less code you mark with
"use client", the fewer places a mismatch can come from. See when to use the "use client" directive. - Treat render functions as pure. No
Math.random(),Date.now(), or browser APIs during render. Put them in effects or event handlers. - Format with explicit locales and time zones whenever you format on the server.
- Develop with a different time zone than your browser. Running
TZ=UTC LANG=ja_JP.UTF-8 npm run devsurfaces locale and time zone mismatches on your own machine instead of in production. - Validate HTML nesting in custom MDX components and design system primitives.
- Watch production errors. React reports recoverable hydration errors through
onRecoverableError, and error monitoring tools capture them, so you can find mismatches that only happen for certain users.
Common Problems and Fixes
- "Text content does not match server-rendered HTML." A value differs between server and client. Look for dates, numbers formatted with
toLocaleString, random values, or browser APIs in the component named in the diff. - "In HTML, div cannot be a descendant of p." Invalid nesting. Change the outer element or the inner one, and check Markdown components that render block elements.
- The error only appears for some users. It depends on their locale, time zone, or extensions. Reproduce with DevTools Sensors to override locale and time zone.
- The error appears on
htmlorbodywith unknown attributes. A browser extension. Confirm in a private window, then addsuppressHydrationWarningto that element. useEffectfixed the error but the content flashes. Use the inline script pattern, or redesign so the default server value is acceptable.ssr: falsethrows an error. It is not allowed in Server Components. Move thedynamic()call into a file marked with"use client".
Hydration Errors FAQ
No. The overlay only appears in development, but the mismatch still happens in production. React silently recovers by re-rendering the affected part of the page on the client, which causes flashes, wasted work, and slower interactivity. Fix them rather than ignoring them.
Their own code cannot, because it never runs in the browser. However, React compares the DOM against the payload they produced, so a browser extension or script that modifies their markup before hydration can still trigger a mismatch. Invalid HTML nesting in a Server Component also causes errors because the browser restructures the DOM.
No. It prevents a crash on the server, but it guarantees that the server and browser take different branches, which is exactly what a hydration mismatch is. Read browser values in an effect or with useSyncExternalStore and a server snapshot instead.
Only for components that genuinely cannot render on the server, such as map or canvas widgets. Disabling server rendering removes the content from the initial HTML, which hurts load performance and search visibility. Most errors have a targeted fix that keeps server rendering.
Incognito windows usually run without extensions. If the error disappears there, a browser extension is adding attributes or elements to the page before React hydrates. It is not a bug in your code, and suppressing the warning on the affected element is reasonable.
Conclusion
A hydration error means the browser's first render did not match the server's HTML. Once you know that, the cause is usually easy to find: a date formatted in two different time zones, a localStorage read during render, a random ID, a div inside a p, a theme chosen in JavaScript, or a browser extension changing the DOM.
The fixes follow the same principle: make the first client render produce exactly what the server produced. Format deterministically on the server, read browser-only values after mount or through useSyncExternalStore, use useId for IDs, write valid HTML, set themes with a blocking script and CSS, and reserve suppressHydrationWarning for differences you have deliberately designed. Keeping most of your tree in Server Components reduces the surface area for all of it.
Here are some useful references for going deeper on hydration:
- React Docs: hydrateRoot — how hydration works and how React handles mismatches.
- Next.js Docs: Preventing Flash Before Hydration — the inline script pattern for dates, themes, and persisted state.
- Next.js Docs: Text content does not match server-rendered HTML — the official error explanation and common solutions.
- React Docs: useSyncExternalStore — subscribing to browser values with a separate server snapshot.
- MDN Web Docs: Content categories — which HTML elements are allowed inside which.


