How to fix RSC hydration mismatches in Next.js
Hydration mismatches are easy to spot in development and frustratingly hard to diagnose in production. In development, React can show the mismatching content, a component stack, and a more readable warning. In production, you may only get a minified error code, a link to React’s error decoder, and a broad category label.

React 19 improves the developer experience by consolidating hydration errors and showing more context than React 18 did. But the core production problem remains: unless you set up instrumentation, you usually do not know which route, component, data source, or client boundary caused the mismatch.
That becomes especially painful in React Server Component (RSC) apps. A hydration mismatch can force React to discard server-rendered HTML and re-render part of the UI, or even the whole root, on the client. That means the app still paid the cost of server rendering, but the user loses some of the performance benefit.
In this guide, we’ll walk through why RSC hydration mismatches are difficult to debug, the most common root causes in Next.js App Router apps, and a production-focused workflow for finding and preventing them.
| Problem | Why it happens | First thing to check |
|---|---|---|
| Browser-only APIs during render | Server and client produce different output | window, document, localStorage, navigator |
| Date, time, and locale output | Server timezone differs from the user’s environment | Date, Intl, relative time helpers |
| Auth or user-state rendering | Server renders logged-out UI, client renders logged-in UI | cookies(), session loading, static rendering |
| Invalid HTML nesting | Browser repairs the DOM before React hydrates it | CMS HTML, |
| Browser extensions or middleware | External code mutates HTML before hydration | Clean profile, CDN bypass, transform rules |
| CSS-in-JS ordering | Server and client generate class names in different orders | styled-components, Emotion, streaming setup |
Why RSC hydration mismatches are hard to debug
React hydration expects the server-rendered HTML to match what the client renders on the first pass. When the two outputs differ, React can recover from some mismatches, but the result is still a bug. At best, the page slows down; at worst, React can attach event handlers to the wrong elements. The official React docs are explicit that hydration errors should be fixed, not ignored.
In a traditional SSR app, there is usually one server render and one client hydration pass. The App Router adds more moving parts: Server Components, Client Components, streaming, Suspense boundaries, dynamic route data, request-time APIs, and the RSC payload that travels alongside the HTML.
That does not make RSC unreliable. It means production debugging needs more structure.
Production hides the useful details
In development, React usually gives you enough information to start debugging: a warning, the mismatching text, and a component stack. In production, the same issue is much less descriptive.
For example, you may see minified React error codes such as #418 or #425. Those codes can tell you the category of problem, but they do not tell you which component rendered different output or which request state triggered the issue.
When a mismatch happens outside a Suspense boundary, React may fall back to client rendering for the root. The user downloads server HTML, then React renders the affected tree again on the client. In an RSC app, that quietly undermines the reason you adopted server rendering and streaming in the first place.
The practical takeaway: production hydration errors need telemetry. Without it, you are guessing.
The App Router expands the failure surface
The "use client" directive is not just a bundling marker. It is a hydration boundary.
Everything above that boundary renders on the server and must produce output the client can match exactly. When browser-dependent logic leaks into that initial render, either directly or through a third-party provider, the client can render something different from the server.
Streaming via Suspense adds timing complexity. With full-page SSR, the server sends a completed document and the client hydrates it once. With streaming, the server can send partial HTML while Suspense boundaries resolve at different times. A component that appears stable during a full static load can still break when rendered under streaming conditions.
There is also the RSC payload to consider. In RSC apps, the HTML may look correct, but the serialized component payload can still diverge from what a Client Component produces during hydration. Keep these two failure modes separate:
- HTML-layer mismatch: The raw HTML bytes sent by the server do not match the DOM React expects. Common causes include invalid nesting and CDN or middleware rewrites.
- Reconciler-layer mismatch: The server HTML and RSC payload are reasonable, but a Client Component renders different output during hydration. Common causes include
localStorage, timezone, locale, auth state, and third-party browser APIs.
The same visible error can cover both cases. A curl diff of the raw HTML can help with the first case, but it will not catch every reconciler-layer issue.
Local reproduction is often misleading
Many hydration bugs only appear in production because next dev uses a different build path and reports mismatches more helpfully instead of matching the production recovery behavior.
Your local environment may also differ from production in subtle ways:
- Timezone
- Locale
- Auth cookies
- A/B flag state
- Edge middleware behavior
- CDN transforms
- Browser extensions
- Static vs. dynamic rendering mode
A common failure pattern is spending two days debugging in next dev, failing to reproduce the issue, and then blaming the CDN. In many cases, the problem is simpler: the app was never tested with next build && next start under production-like conditions.
The six most common causes of RSC hydration mismatches
Most production hydration mismatches fall into a few repeatable categories. Start with these before looking for exotic RSC bugs.
1. Browser-only APIs accessed during render
This is still the most common cause of hydration mismatches. It is often not your own code, either. It may be a third-party provider added near the top of the tree that reads window, document, localStorage, or navigator during initialization.
Analytics SDKs, feature flag providers, A/B testing tools, session replay tools, and personalization libraries can all do this if they are initialized too early.
Here is the problematic pattern:
// app/layout.tsx
import { FeatureFlagProvider } from '@acme/feature-flags';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{/* This provider calls localStorage.getItem('ff_overrides') during render. */}
{children}
);
}
The fix is not to remove feature flags. The fix is to make the initial render deterministic. Let the Server Component evaluate server-safe flags and pass them into a Client Component. Then read local overrides after hydration:
// components/feature-flag-provider-wrapper.tsx
'use client';
import { useEffect, useState } from 'react';
import { FeatureFlagProvider } from '@acme/feature-flags';
export function FeatureFlagProviderWrapper({
children,
serverFlags,
}: {
children: React.ReactNode;
serverFlags: Record;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Server render and initial client render use the same data.
if (!mounted) {
return (
{children}
);
}
// Browser-only overrides run after hydration.
return (
{children}
);
}
The serverFlags prop is the key. The server and initial client render agree on the same flag values. Local browser overrides enhance the UI after hydration instead of changing the first render.
This is the same principle Next.js recommends for client-only differences: render the same content on the server and first client pass, then intentionally update it in useEffect.
2. Date, time, and locale-dependent rendering
Your server may run in UTC. Your users probably do not. Date.toLocaleString(), Intl.DateTimeFormat, and helpers such as formatDistanceToNow() can produce different strings depending on timezone, locale, and time elapsed between the server render and client hydration.
For example, the server might render posted 3 minutes ago. By the time the client hydrates, it may be posted 5 minutes ago. That small difference is enough to produce a mismatch.
This shows up in dashboards, blog metadata, order histories, notification feeds, and any UI that renders human-readable time.
More great articles from LogRocket:
Problematic version:
// components/post-meta.tsx
export async function PostMeta({ post }: { post: Post }) {
const timeAgo = formatDistanceToNow(new Date(post.publishedAt), {
addSuffix: true,
});
return (
{post.author.name}
{timeAgo}
);
}
Safer version:
// components/post-meta.tsx
export async function PostMeta({ post }: { post: Post }) {
return (
{post.author.name}
);
}
// components/relative-time.tsx
'use client';
import { useEffect, useState } from 'react';
import { format, formatDistanceToNow } from 'date-fns';
export function RelativeTime({ dateTime }: { dateTime: string }) {
const [relativeTime, setRelativeTime] = useState(null);
const date = new Date(dateTime);
useEffect(() => {
setRelativeTime(formatDistanceToNow(date, { addSuffix: true }));
const interval = setInterval(() => {
setRelativeTime(formatDistanceToNow(date, { addSuffix: true }));
}, 60_000);
return () => clearInterval(interval);
}, [dateTime]);
return (
);
}
The server renders a stable absolute date. The client replaces it with relative time after mount.
suppressHydrationWarning is appropriate here because the mismatch is expected, limited to a leaf element, and intentionally managed. It is not a general-purpose escape hatch. If you add suppressHydrationWarning to a large wrapper because you do not know where the mismatch is coming from, you are hiding the bug instead of fixing it.
3. Authentication and user-state conditional rendering
This shows up often in App Router apps that use middleware-based auth, including Clerk, NextAuth/Auth.js, and custom session middleware.
The pattern is usually the same: the server renders logged-out UI because the route was statically rendered or the component did not read request cookies, while the client reads a token and renders logged-in UI. The whole header then re-renders on every page load for logged-in users.
Problematic version:
// components/site-header.tsx
import { getSession } from '@/lib/auth';
export async function SiteHeader() {
const session = await getSession();
return (
);
}
Safer version:
// components/site-header.tsx
import { cookies } from 'next/headers';
import { validateSession } from '@/lib/auth';
export async function SiteHeader() {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session_token')?.value;
const session = sessionToken ? await validateSession(sessionToken) : null;
return (
);
}
cookies() from next/headers gives a Server Component access to request cookies, so the server and client can agree on the initial auth state. As of Next.js 15, cookies() is asynchronous and should be awaited. Next.js 16 fully removes synchronous access to request-time APIs such as cookies(), headers(), and draftMode().
There is a trade-off: cookies() is a request-time API, so using it in a layout or page opts the route into dynamic rendering. That is usually the correct trade-off for auth-dependent UI.
If you truly need static or ISR behavior, avoid rendering different logged-in and logged-out markup as the hydration target. Use a stable skeleton, defer auth-dependent UI to the client, or isolate it behind an appropriate Suspense boundary.
4. Invalid HTML nesting
Browsers silently repair invalid HTML. React does not hydrate against your source string; it hydrates against the DOM the browser constructed from it.
For example, if a browser sees a
, it closes the paragraph before the
. React then sees a different tree than the one it expected and reports a hydration mismatch.
This is common with CMS-rendered rich text:
Great product.
Note: Ships in 3–5 days.
The browser repairs that into something closer to this:
Great product.
Note: Ships in 3–5 days.
If you render CMS HTML directly, sanitize and normalize it before React sees it:
// lib/sanitize-cms-html.ts
import { parseDocument } from 'htmlparser2';
import { render } from 'dom-serializer';
const BLOCK_ELEMENTS = new Set([
'article',
'aside',
'div',
'figure',
'ol',
'section',
'table',
'ul',
]);
export function sanitizeCmsHtml(html: string): string {
const doc = parseDocument(html);
function unwrapBlocksFromParagraphs(nodes: any[]): any[] {
return nodes.flatMap((node) => {
if (node.type === 'tag' && node.name === 'p') {
const hasBlockChildren = node.children.some(
(child: any) => child.type === 'tag' && BLOCK_ELEMENTS.has(child.name)
);
if (hasBlockChildren) {
return node.children;
}
}
return node;
});
}
doc.children = unwrapBlocksFromParagraphs(doc.children as any[]);
return render(doc);
}
// components/product-description.tsx
import { sanitizeCmsHtml } from '@/lib/sanitize-cms-html';
export function ProductDescription({ html }: { html: string }) {
const sanitized = sanitizeCmsHtml(html);
return (
);
}
For your own components, keep the DevTools console open in development and watch for validateDOMNesting(...) warnings. For CMS content, validate and sanitize HTML server-side so the server output and client DOM start from the same valid structure.
5. Browser extensions and CDN middleware
Some hydration errors are not caused by your source code. Browser extensions, password managers, translation tools, and grammar tools can inject or restructure DOM nodes before React hydrates the page.
CDN and edge middleware can also introduce mismatches if they rewrite HTML in transit. Historically, this included minification behavior that changed whitespace or markup. Today, look for custom transform rules, Workers, middleware, and response rewriting rather than assuming the CDN is the problem.
React 19 improved hydration behavior around some extension-injected nodes in and , so these issues are less noisy than they were in React 18. Still, extension-related errors can appear in monitoring, especially for users on older React versions or pages with fragile markup.
For root-level extension injection, React’s documented escape hatch is to suppress hydration warnings on or only:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
This is different from suppressing warnings deep in your application tree. suppressHydrationWarning only works one level deep, and it should not be used to hide unknown mismatches in product UI.
To diagnose extension and middleware issues:
- Reproduce in a clean browser profile with all extensions disabled
- Test the same route while bypassing the CDN
- Compare the raw server response with the live DOM
- Filter known extension signatures in your monitoring tool
If the mismatch only appears on or , suspect extension injection before auditing every component in the page.
6. CSS-in-JS class name ordering
CSS-in-JS class name mismatches are less common than they used to be, but they still matter in older codebases using styled-components or Emotion, especially after adding App Router streaming.
These libraries can generate class names based on render order. Streaming and Suspense can change when components render, which can produce different class names on the server and client if the style registry is not configured correctly.
For styled-components in the App Router, use a registry with useServerInsertedHTML so server-generated styles are inserted consistently during streaming:
// app/styled-components-registry.tsx
'use client';
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => (
));
if (typeof window !== 'undefined') {
return <>{children}>;
}
return (
{children}
);
}
The key is not the exact library. The key is making style insertion deterministic across server rendering, streaming, and hydration.
A production debugging workflow
Work through these steps in order. Do not skip to the HTML diff just because it feels more concrete. The goal is to narrow the search space before you start comparing giant documents.
Step 1: Capture hydration errors before React hydrates
Without production instrumentation, you are guessing. With it, you can tie hydration errors to a route, browser, user agent, session recording, deployment, and navigation path.
In current Next.js, instrumentation-client.ts is a useful client-side entrypoint because it runs after the HTML document loads but before React hydration begins. That timing makes it appropriate for lightweight error and performance monitoring.
Create instrumentation-client.ts in the root of your project, next to app/ or src/:
// instrumentation-client.ts
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
const message = typeof args[0] === 'string' ? args[0] : '';
const isHydrationError =
message.includes('418') ||
message.includes('425') ||
message.includes('Hydration') ||
message.includes('hydration') ||
message.includes('did not match');
if (isHydrationError) {
const payload = JSON.stringify({
message: args.map(String).join(' '),
url: window.location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
});
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon('/api/telemetry/hydration-error', blob);
} else {
fetch('/api/telemetry/hydration-error', {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: payload,
}).catch(() => {
// Fire and forget.
});
}
}
originalConsoleError.apply(console, args);
};
If you use a monitoring or session replay tool, this is where you would attach the same payload to the current session. For example, with LogRocket, you can correlate hydration errors with the user session to see what the page looked like when React recovered.
A note on naming: in a custom React app, you can pass onRecoverableError directly to hydrateRoot. In a Next.js app, you generally do not control that hydrateRoot call, so the practical integration point is client instrumentation and monitoring rather than a literal hydrateRoot option.
Step 2: Reproduce locally under production conditions
Do not rely on next dev. Development mode is designed for debugging and can behave differently from production hydration.
Use a production build:
next build && next start
Match production timezone and locale when testing date-related bugs:
TZ=UTC next build && TZ=UTC next start
LANG=en-US.UTF-8 TZ=UTC next start
Before testing, disable browser extensions. Use a fresh Chrome profile or an incognito window, then test with real production-like auth cookies and feature flags.
Step 3: Use Suspense boundaries to isolate the mismatch
Suspense boundaries are not only a loading UI tool. They are also useful for debugging hydration failures.
When a hydration mismatch occurs inside a Suspense boundary, React can limit recovery to that subtree instead of switching the entire root to client rendering. You can use that behavior to narrow the failure down.
Wrap major sections one at a time. If the page-level mismatch disappears after wrapping a section, the bug is likely inside that section:
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
export default async function ProductPage({
params,
}: {
// Next.js 15+: params is a Promise.
// In Next.js 14, use: params: { slug: string }.
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await fetchProduct(slug);
return (
}>
}>
}>
);
}
Once you isolate the section, go deeper until you reach the exact component. Remove any temporary debugging boundaries after you ship the fix unless they also make sense architecturally.
Step 4: Diff server HTML against the live client DOM
After you have a suspect route or component, compare the server’s initial HTML with the browser’s post-hydration DOM:
curl -s " \
-H "Cookie: session_token=YOUR_SESSION_TOKEN" \
> /tmp/server-render.html
Then, in Chrome DevTools after the page loads:
copy(document.documentElement.outerHTML)
Save that output to /tmp/client-render.html, then compare:
diff /tmp/server-render.html /tmp/client-render.html | head -100
You can also do this manually in DevTools. In the Network tab, filter by Doc, open the page request, and inspect the Response tab for the raw server HTML. Compare that to the Elements panel, which shows the live DOM after JavaScript runs.
Look for:
- Text differences, especially timestamps and locale-formatted strings
- Different element counts, especially auth-dependent UI
- Different attributes, especially class names and data attributes
- Browser-repaired markup from invalid HTML nesting
- Nodes injected by extensions or middleware
Remember that this catches HTML-layer mismatches more reliably than reconciler-layer mismatches. If the raw HTML looks fine, keep looking at Client Component output and browser-only state.
Step 5: Trace the mismatch back to the input
Once you find the mismatching element, trace it back to the data or logic that produced it. Ask these questions in order:
- Does this component read environment-specific data such as timezone, locale,
window, document, localStorage, or navigator?
- Does it depend on auth state, cookies, feature flags, or A/B test assignments?
- Is the HTML structurally valid?
- Was the HTML modified by a CDN, Worker, transform rule, or middleware?
- Does the error disappear in a clean browser profile with no extensions?
- Does the component rely on CSS-in-JS class name generation during streaming?
This list covers most production incidents. Start there before assuming the RSC payload itself is corrupt.
Architectural patterns that prevent hydration mismatches
Fixing one hydration mismatch is useful. Preventing the next one is better. These patterns keep server and client output aligned by design.
Pattern 1: Treat Server Components as pure functions of data
Server Component output must be deterministic given the same input data. Do not read browser APIs, local timezone, client-only auth state, or mutable client storage during the server render.
Think of the "use client" boundary as a line between two worlds:
- Server output must be stable and reproducible
- Client output can depend on browser state after hydration
For example, fetch server-safe data in the Server Component, then pass stable props to a Client Component that handles browser-dependent rendering:
// app/dashboard/page.tsx
export default async function DashboardPage() {
const [metrics, user] = await Promise.all([
fetchDashboardMetrics(),
getCurrentUser(), // Uses cookies() internally.
]);
return (
);
}
// components/activity-chart.tsx
'use client';
export function ActivityChart({
data,
userTimezone,
}: {
data: ActivityDataPoint[];
userTimezone: string;
}) {
const formattedData = data.map((point) => ({
...point,
label: new Intl.DateTimeFormat('en-US', {
timeZone: userTimezone,
hour: 'numeric',
}).format(new Date(point.timestamp)),
}));
return ;
}
The Client Component can use browser APIs because it is explicitly client-rendered. The Server Component still provides stable input.
Pattern 2: Render a stable baseline, then enhance on the client
For UI that depends on browser state, render a server-safe baseline first. Then enhance it in useEffect.
This is the difference between patching a mismatch and designing around it. useEffect deferral stops the immediate mismatch; a stable baseline makes the pattern repeatable across the codebase.
// components/recently-viewed.tsx
'use client';
import { useEffect, useState } from 'react';
export function RecentlyViewed({
currentProductId,
}: {
currentProductId: string;
}) {
const [products, setProducts] = useState(null);
useEffect(() => {
const recent = getRecentlyViewed(currentProductId);
setProducts(recent);
}, [currentProductId]);
if (products === null) {
return ;
}
if (products.length === 0) {
return null;
}
return (
Recently viewed
{products.map((product) => (
))}
);
}
The skeleton is the stable baseline. The product cards are the client enhancement. The server and first client render agree, and the UI improves after mount.
Pattern 3: Add hydration smoke tests to CI
Manual testing will miss hydration regressions. Add a production-build smoke test for critical routes so hydration errors fail CI before they reach users.
// tests/hydration.spec.ts
import { test, expect } from '@playwright/test';
const CRITICAL_ROUTES = [
'/',
'/products/test-product-slug',
'/dashboard',
'/checkout',
];
test.describe('Hydration smoke tests', () => {
for (const route of CRITICAL_ROUTES) {
test(`no hydration errors on ${route}`, async ({ page }) => {
const hydrationErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() !== 'error') return;
const text = msg.text();
if (
text.includes('418') ||
text.includes('425') ||
text.includes('Hydration') ||
text.includes('hydration') ||
text.includes('did not match')
) {
hydrationErrors.push(text);
}
});
await page.goto(route, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
expect(hydrationErrors).toEqual([]);
});
}
});
Run this against next build && next start, never next dev. Make it a required check for routes where hydration failures would seriously affect performance or conversion, such as product pages, dashboards, checkout, and logged-in homepages.
Conclusion
When you hit a production hydration error, work through the problem in this order:
- Capture hydration errors with
instrumentation-client.ts so you know which route, browser, and session triggered the issue
- Reproduce with
next build && next start, not next dev
- Match production timezone, locale, auth cookies, and feature flags
- Use temporary Suspense boundaries to isolate the mismatching subtree
- Diff server HTML against the live client DOM to find visible divergence
- Trace the mismatch to browser APIs, timezones, auth state, invalid HTML, extensions, middleware, or CSS-in-JS ordering
- Fix at the right scope: client-only enhancement for small expected differences, or a Server/Client boundary refactor for structural issues
- Add Playwright smoke tests so the same class of bug does not ship again
Hydration mismatches are not React being overly strict. They are real performance and correctness bugs. Every mismatch means React could discard server-rendered work that your app already paid for.
In RSC applications, where the goal is to reduce client JavaScript and stream useful UI earlier, hydration failures quietly erase those benefits. Keep browser-dependent logic below the "use client" boundary, render stable server baselines, and test hydration under production conditions before users are the ones who find the mismatch.