Fix 'Hydration failed' in Next.js: 8 Root Causes (2026)
Hydration mismatch errors breaking your Next.js app? Learn the root causes and 8 proven fixes to eliminate these errors permanently.
A Next.js app that works perfectly in development can still fail silently in production with cryptic hydration errors — not a crash, just a console full of warnings and UI that doesn't behave the way the server-rendered markup suggested it should.
"Text content does not match server-rendered HTML."
"Hydration failed because the initial UI does not match what was rendered on the server."
Both messages point at the same underlying problem: the HTML React generated on the server differs from what it generates when it re-renders on the client. Per React's own hydrateRoot reference, the fix is always the same in shape — make the two renders produce identical output — even though the cause varies. Below are the 8 causes that account for nearly every hydration mismatch in a Next.js App Router or Pages Router project, each with the code that triggers it and the code that fixes it.
This article is part of our comprehensive Deploying Next.js + Supabase to Production guide.
The Problem: What Even Is Hydration?#
Before fixing anything, here's what "hydration" actually means in this context.
Next.js renders your page twice.
- Server renders HTML - Next.js generates static HTML on the server (fast initial load)
- Browser receives HTML - User sees content immediately
- React "hydrates" - JavaScript downloads and makes the page interactive
- Mismatch detected - If the HTML from step 1 doesn't match what React generates in step 3, you get hydration errors
The tricky part: a dev server does client-side rendering by default, so mismatches that would break a production SSR page often go unnoticed until you run a production build.
The Errors You'll See#
Each of these variants points at the same root cause — a markup mismatch — but with different diagnostic detail:
# The classic one
Warning: Text content did not match. Server: "2/16/2026, 10:00 AM" Client: "2/16/2026, 2:00 AM"
# The confusing one
Warning: Prop `className` did not match. Server: "dark" Client: "light"
# The vague one
Warning: Expected server HTML to contain a matching <div> in <div>
# The scary one
Error: Hydration failed because the initial UI does not match what was rendered on the serverFix #1: The Date/Time Mismatch (The Most Common Cause)#
A server that renders in UTC (the default on most hosts, including Vercel) and a client browser in a local timezone will format the same Date differently. The server renders one string, the client renders another, and React flags the mismatch.
// ❌ Formats in the viewer's local timezone — differs from the server
export function PostDate({ date }: { date: Date }) {
return <time>{date.toLocaleString()}</time>
// Server (UTC): "2/16/2026, 10:00:00 AM"
// Client (PST): "2/16/2026, 2:00:00 AM"
// ❌ Hydration mismatch!
}Three fixes, ranked from quick patch to actual solution:
Quick Fix: suppressHydrationWarning (Use Sparingly)#
This suppresses the warning without fixing the underlying mismatch.
// ✅ Quick fix: Suppress the warning
export function PostDate({ date }: { date: Date }) {
return (
<time suppressHydrationWarning>
{date.toLocaleString()}
</time>
)
}This tells React "yeah, I know the content will be different, just ignore it." It works, but use it only for unavoidable cases like timestamps.
Better Fix: Client-Side Only Rendering#
For user-specific, timezone-dependent timestamps: render a safe, timezone-neutral fallback on the server, then swap in the real formatted time after mount.
'use client'
import { useEffect, useState } from 'react'
export function PostDate({ date }: { date: Date }) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
// Server sees this: safe, no timezone issues
return <time>{date.toISOString().split('T')[0]}</time>
}
// Client sees this: formatted in user's timezone
return <time>{date.toLocaleString()}</time>
}The trick: both server and client render the same thing initially (the ISO date), then the client updates to show the localized time. No mismatch.
Best Fix: Use UTC Consistently#
When the display timezone doesn't have to be the viewer's local one (a blog post publish date, for instance), format in a fixed timezone on both server and client. This is the simplest fix and needs no client-side effect at all.
// ✅ Fixed timezone on both server and client
export function PostDate({ date }: { date: Date }) {
const formatted = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
return <time>{formatted}</time>
}Both server and client render the exact same UTC time. No mismatch, no suppressHydrationWarning needed.
Fix #2: localStorage/sessionStorage Access#
Browser APIs don't exist on the server. Reading localStorage (or sessionStorage, window, document) directly in a component's render body throws on the server and, if narrowly avoided, still produces different first-render output between server and client.
// ❌ localStorage does not exist on the server
export function ThemeToggle() {
const theme = localStorage.getItem('theme') || 'light'
// ❌ ReferenceError: localStorage is not defined (server)
return <button>{theme}</button>
}The Fix#
Move all localStorage access into useEffect. It only runs on the client, never on the server.
'use client'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const [theme, setTheme] = useState('light') // Server renders this
useEffect(() => {
// This only runs on the client, after hydration
const savedTheme = localStorage.getItem('theme') || 'light'
setTheme(savedTheme)
}, [])
return <button>{theme}</button>
}Server renders "light", client hydrates with "light", then useEffect updates to the saved theme. No mismatch.
Pro Tip: Make a Reusable Hook#
If this pattern shows up in more than one component, extract it into a hook:
// hooks/useLocalStorage.ts
'use client'
import { useEffect, useState } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(initialValue)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
try {
const item = window.localStorage.getItem(key)
if (item) {
setValue(JSON.parse(item))
}
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error)
}
}, [key])
const setStoredValue = (newValue: T) => {
try {
setValue(newValue)
window.localStorage.setItem(key, JSON.stringify(newValue))
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}
return [mounted ? value : initialValue, setStoredValue] as const
}
// Now it's one line:
export function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light')
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
)
}Fix #3: Random Values#
Random IDs used for accessibility attributes (or any DOM attribute) will differ between the server render and the client render, since each call to Math.random() produces a new value.
The Mistake#
// ❌ I was doing this (generates different IDs each time)
export function RandomComponent() {
const id = Math.random().toString(36)
// Server generates: "0.abc123"
// Client generates: "0.xyz789"
// ❌ Different IDs = hydration mismatch!
return <div id={id}>Content</div>
}Every render generates a new random number. Server gets one, client gets another. Mismatch.
The Fix: React's useId Hook#
React 18 added a hook specifically for this. It generates IDs that match between server and client.
'use client'
import { useId } from 'react'
export function RandomComponent() {
// React's useId generates consistent IDs across server/client
const id = useId()
return <div id={id}>Content</div>
}Per the useId reference docs, the hook derives its value from the component's position in the tree, so it returns the same identifier on the server and the client as long as the tree structure matches. Marking the component 'use client' is required here because useId is a hook, but it does not skip server-side rendering — the element is still emitted as HTML during the server pass and then hydrated. To exclude a component from the server render entirely, use next/dynamic with { ssr: false } instead (see Fix #5).
Solution: Pass ID as Prop#
// Generate ID on server, pass as prop
export function ParentComponent() {
const id = crypto.randomUUID()
return <RandomComponent id={id} />
}
export function RandomComponent({ id }: { id: string }) {
return <div id={id}>Content</div>
}Fix #4: Conditional Rendering Based on Window#
Guarding with typeof window !== 'undefined' looks like the right fix, but it produces a mismatch of its own: the check itself evaluates differently on server and client.
The Problem#
// ❌ This seemed smart but caused hydration errors
export function ResponsiveComponent() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// Server: false (window doesn't exist)
// Client: true (on mobile device)
// ❌ Different content = hydration mismatch!
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>
}The server always renders "Desktop" (window is undefined), but the client renders "Mobile" on mobile devices. Mismatch.
Best Fix: CSS Media Queries#
CSS already handles responsive layout without any client/server divergence.
// ✅ This is what I should have done from the start
export function ResponsiveComponent() {
return (
<>
<div className="block md:hidden">Mobile</div>
<div className="hidden md:block">Desktop</div>
</>
)
}Both elements render on server and client. CSS shows/hides them based on screen size. No JavaScript, no hydration errors, better performance.
Solution: Client-Side Detection#
'use client'
import { useEffect, useState } from 'react'
export function ResponsiveComponent() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Render same content on server and initial client render
return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>
}Fix #5: Third-Party Libraries That Assume a Browser#
Many client-side libraries — chart, map, and rich-text-editor packages in particular — access window or document as soon as they're imported, not just when they're rendered. Importing one directly into a component that gets server-rendered breaks the build or the hydration pass.
The Problem#
// ❌ Library accesses window/document at import or render time
import Chart from 'some-chart-library'
export function ChartComponent() {
return <Chart data={data} />
// ❌ Library tries to access window.document on server
// Build fails or hydration errors everywhere
}Many libraries (charts, maps, rich text editors) expect to run in a browser. They access window, document, or other browser APIs immediately.
The Fix: Dynamic Import with ssr: false#
Next.js's next/dynamic supports an ssr: false option that skips server rendering for the wrapped component entirely.
import dynamic from 'next/dynamic'
// ✅ Skip SSR for this component
const Chart = dynamic(() => import('some-chart-library'), {
ssr: false, // Don't render on server
loading: () => <div>Loading chart...</div>, // Show while loading
})
export function ChartComponent() {
return <Chart data={data} />
}The component only renders on the client. Server shows the loading state, client shows the actual chart. No hydration errors.
Solution: Wrap in Client Component#
// components/ChartWrapper.tsx
'use client'
import Chart from 'some-chart-library'
export function ChartWrapper({ data }) {
return <Chart data={data} />
}
// page.tsx (Server Component)
import { ChartWrapper } from '@/components/ChartWrapper'
export default function Page() {
return <ChartWrapper data={data} />
}Fix #6: Invalid HTML Nesting#
Browsers silently rewrite invalid HTML nesting — a <div> inside a <p>, a <button> inside a <button> — into valid markup as they parse it. React's virtual DOM doesn't know about that rewrite, so the DOM the browser actually built no longer matches what React thinks it rendered.
The Problem#
// ❌ Invalid nesting
export function BlogPost() {
return (
<p>
<div className="highlight">This is invalid HTML</div>
</p>
)
}
// ❌ And this in a card component
export function Card() {
return (
<button>
<button>Click</button> {/* Nested buttons! */}
</button>
)
}Server sends <p><div>, the browser parser corrects it to <p></p><div>, and React sees a mismatch against the tree it expected.
The Fix: Valid HTML#
Run the markup through an HTML validator and fix the nesting.
// ✅ Fixed: Use div instead of p for containers
export function BlogPost() {
return (
<div>
<p>Paragraph text</p>
<div className="highlight">Highlighted content</div>
</div>
)
}
// ✅ Fixed: No nested interactive elements
export function Card() {
return (
<div>
<button onClick={handleClick}>Click</button>
</div>
)
}The HTML content model rules that matter most for React apps:
<p>can only contain "phrasing content" (no<div>,<section>, etc.)<button>cannot contain other interactive elements<a>cannot contain other<a>tags
Fix #7: Browser Extensions#
Extensions like Grammarly, password managers, and ad blockers inject their own HTML into a page after it loads. The server has no way to know about that injected markup, so the client DOM (extension elements included) no longer matches what the server rendered. Testing in an incognito window with extensions disabled is the fastest way to confirm this is the cause — if the error disappears there, it's an extension.
The Fix: Suppress on Root Elements Only#
For the <html> and <body> tags, it's safe to suppress hydration warnings. Extensions will modify these, and there's nothing you can do about it.
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>
{children}
</body>
</html>
)
}This is one of the few legitimate uses of suppressHydrationWarning. Extensions will inject code into html/body, and that's okay.
Solution: Detect and Handle Extensions#
'use client'
import { useEffect, useState } from 'react'
export function ExtensionSafeComponent() {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
if (!isClient) {
return <div>Loading...</div>
}
return <div>Content safe from extensions</div>
}Fix #8: Debugging Tools#
When the obvious causes above don't explain a mismatch, these three tools narrow it down.
Step 1: Enable React Strict Mode#
// next.config.mjs
const nextConfig = {
reactStrictMode: true,
}Strict Mode double-invokes render functions and effects in development, which surfaces impure render logic — a common source of hydration bugs — earlier than it would otherwise appear.
Step 2: Use React DevTools#
Install the React DevTools browser extension and enable "Highlight updates when components render." A component that re-renders with different content immediately after hydration will flash, making it easy to spot which subtree is diverging.
Step 3: Add Error Boundaries#
Wrap suspicious components in an error boundary to catch and log the mismatch with its stack, rather than relying on the console warning alone.
'use client'
import { Component, ReactNode } from 'react'
export class HydrationErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error, errorInfo) {
console.error('Hydration error caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return <div>Something went wrong. Please refresh.</div>
}
return this.props.children
}
}Wrap your components:
<HydrationErrorBoundary>
<SuspiciousComponent />
</HydrationErrorBoundary>Verifying the Fix: Check the Server-Rendered HTML Directly#
After applying a fix, confirm the server is actually emitting the corrected markup rather than trusting the console going quiet. Start a production build and curl the raw HTML:
npm run build && npm run startcurl -s http://localhost:3000/ | grep 'your-marker'If the server-rendered HTML contains the stable value (a useId-generated id, the UTC-formatted date, etc.), the fix is confirmed at the source rather than inferred from the absence of a warning.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Common Mistakes#
- Using
suppressHydrationWarningeverywhere — it hides the warning without fixing the underlying bug; reserve it for genuinely unavoidable cases (timestamps, or extension interference on<html>/<body>). - Not testing production builds —
npm run devrenders client-side and hides mismatches thatnpm run build && npm run startexposes. - Ignoring timezone differences — server and client are frequently in different timezones; format dates in a fixed timezone unless the local one is actually needed.
- Accessing
window/localStorageduring render — render runs on the server too; move browser-only reads intouseEffect. - Not checking HTML validity — browsers auto-correct invalid nesting, and React doesn't know about the correction.
FAQ#
Are hydration warnings actually serious?#
Yes. They indicate the app's HTML differs between server and client, which shows up as broken interactivity, content flicker, or stale data — and it can affect SEO, since search engines index the server-rendered HTML.
Can I just add suppressHydrationWarning everywhere?#
No — it hides the warning but leaves the underlying bug in place. Reserve it for genuinely unavoidable cases: timestamps, or extension-injected markup on the <html>/<body> tags.
Why do hydration errors only appear in production?#
The Next.js dev server renders more forgivingly and doesn't always surface every mismatch the way a production SSR pass does. Always test with npm run build && npm run start before deploying.
Do hydration errors affect SEO?#
Yes. Search engines index the server-rendered HTML; if what users see after hydration is meaningfully different, that's a signal worth fixing independent of the UX cost.
How do I test for hydration errors before deploying?#
- Enable React Strict Mode in
next.config.mjs. - Run
npm run build && npm run start(notnpm run dev). - Test in an incognito window (no browser extensions).
- Test with different system timezones.
- Test on mobile and desktop viewports.
- Check the console — and the curl-verified server HTML — for warnings.
Deploying your Next.js app? Vercel is built by the Next.js team — zero-config deploys, automatic preview environments, and built-in performance monitoring. Start free on Vercel →
Conclusion#
Hydration mismatch errors are caused by inconsistent rendering between server and client. The most common causes are date/time differences, browser API access, random values, unguarded third-party libraries, invalid HTML nesting, and browser extensions.
The fix is always the same in shape: ensure server and client render identical HTML. Use useEffect for client-only code, useId for stable identifiers, suppressHydrationWarning only for unavoidable cases, and always verify against a production build.
Further reading: Next.js 'Module not found: Can't resolve': 6 Fixes, Fix Next.js revalidatePath Not Working in Server Actions. Also see Fix: Do Not Use Array Index in Keys (React).
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Next.js revalidatePath Not Working in Server Actions
Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including a URL served through a rewrite and a second page that reads the same tagged data.
Next.js Turbopack Stuck on Compiling? 5 Fixes (2026)
Turbopack stuck on compiling in Next.js 15/16? Learn the exact causes and 5 proven fixes, plus how to disable Turbopack safely with next build --webpack when production builds crash.
Next.js 'Module not found: Can't resolve': 6 Fixes
Module not found errors only in production? Learn why Next.js builds fail after deploy and get 6 proven fixes that work on Vercel, AWS, and other platforms.


