- Published on
Next.js 16 Hydration Mismatch Errors: Every Cause and Fix (App Router)
- Authors

- Name
- Salman
- X
- @lamebrowndev

The error that tells you nothing
You open your console and see this:
Hydration failed because the initial UI does not match what was rendered on the server.
Or sometimes:
Text content does not match server-rendered HTML.
That is it. No file name. No line number. No hint about which component caused it. Just a vague message telling you that something somewhere is different between the server and the client.
I have been dealing with these errors for years across dozens of Next.js apps. They all come from the same root cause: the HTML generated on the server does not match the HTML generated on the client during hydration. But the specific reasons vary a lot. This post is every cause I have actually seen in production and the fix for each.
Cause 1: Dates and times
This is the most common one. You render a date on the server and it looks different on the client because the timezone or locale is different.
// This will cause a hydration mismatch
export default function Post({ date }: { date: string }) {
return <time>{new Date(date).toLocaleDateString()}</time>
}
The server might be running in UTC. The client might be in PST. The date string is different. Hydration fails.
Fix: Format the date the same way on both sides. Use a fixed locale and timezone.
export default function Post({ date }: { date: string }) {
const formatted = new Date(date).toLocaleDateString('en-US', {
timeZone: 'UTC'
})
return <time>{formatted}</time>
}
Or if you need the user's timezone, render the date only after hydration:
'use client'
import { useState, useEffect } from 'react'
export default function Post({ date }: { date: string }) {
const [formatted, setFormatted] = useState<string | null>(null)
useEffect(() => {
setFormatted(new Date(date).toLocaleDateString())
}, [date])
if (!formatted) {
return <time suppressHydrationWarning>{new Date(date).toISOString()}</time>
}
return <time>{formatted}</time>
}
The suppressHydrationWarning prop tells React to not warn about the mismatch on that specific element. Use it sparingly.
Cause 2: Math.random() and Date.now()
Any value that is different on every render will cause a mismatch if it runs on both server and client.
// This breaks
export default function Banner() {
return <div id={`banner-${Math.random()}`} />
}
The server generates one random number. The client generates another. Mismatch.
Fix: Generate the random value in a useEffect so it only runs on the client.
'use client'
import { useState, useEffect } from 'react'
export default function Banner() {
const [id, setId] = useState('banner')
useEffect(() => {
setId(`banner-${Math.random()}`)
}, [])
return <div id={id} />
}
The initial render uses the static string. The client updates it after hydration. No mismatch.
Cause 3: Browser-only APIs in the render
You called window.innerWidth or navigator.userAgent or localStorage.getItem during render. These do not exist on the server.
// This breaks
export default function Layout({ children }: { children: React.ReactNode }) {
const isMobile = window.innerWidth < 768
return <div className={isMobile ? 'mobile' : 'desktop'}>{children}</div>
}
On the server, window is undefined and this throws. Or if you guarded it with a check, the server renders one thing and the client renders another.
Fix: Use a state that starts with a default and updates after mount.
'use client'
import { useState, useEffect } from 'react'
export default function Layout({ children }: { children: React.ReactNode }) {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
setIsMobile(window.innerWidth < 768)
}, [])
return <div className={isMobile ? 'mobile' : 'desktop'}>{children}</div>
}
The server renders with isMobile = false. The client also starts with false and then updates. No mismatch because the initial render matches.
Cause 4: Conditional rendering based on environment
You checked process.env.NODE_ENV or process.env.NEXT_PUBLIC_SOMETHING and the value is different on the server vs the client.
// This can break
export default function DebugInfo() {
if (process.env.NODE_ENV === 'development') {
return <div>Debug mode</div>
}
return null
}
This usually works because NODE_ENV is the same on both sides. But if you are using a custom env var that is only available on the server, you get a mismatch.
// This breaks
export default function AdminBanner() {
if (process.env.IS_ADMIN === 'true') {
return <div>Admin Panel</div>
}
return null
}
IS_ADMIN is set on the server but not exposed to the browser. The server renders the banner. The client does not. Mismatch.
Fix: Use NEXT_PUBLIC_ prefix for any env var you read during render, or move the check to a client component with a state.
Cause 5: Third-party scripts that modify the DOM
Google Analytics, Hotjar, chat widgets, and other third-party scripts inject HTML into your page. When React tries to hydrate, the DOM does not match what React expects.
This one is tricky because you did not write the code that modifies the DOM. But you still get the error.
Fix: Load third-party scripts with next/script and set the strategy to afterInteractive or lazyOnload.
import Script from 'next/script'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>
</>
)
}
If the script still modifies the DOM before hydration, you can add suppressHydrationWarning to the body tag or the specific container.
Cause 6: Browser extensions
This is the one that drives people crazy. Grammarly, password managers, dark mode extensions, and ad blockers all inject HTML into your page. When React hydrates, it sees extra elements and throws a mismatch error.
You spend hours debugging your code and it is not your code at all. It is the user's browser extension.
How to tell: Open your app in incognito mode with all extensions disabled. If the error goes away, it is an extension.
Fix: There is no real fix for this one. You cannot control what extensions your users have installed. But you can suppress the warning on specific elements that extensions tend to modify:
<body suppressHydrationWarning>
{children}
</body>
This tells React to not check the body's children for mismatches. It is a pragmatic choice. You lose some safety but you stop getting false positive errors from extensions.
Cause 7: Invalid HTML nesting
React is strict about HTML nesting. If you put a <div> inside a <p> or a <p> inside a <p>, the browser fixes it during parsing and React sees a different tree.
// This breaks
export default function Text() {
return (
<p>
Some text
<div>A nested div</div>
</p>
)
}
The browser moves the div outside the p during parsing. React expects the div inside the p. Mismatch.
Fix: Use valid HTML nesting. If you need a block element inside text, use a span or restructure.
export default function Text() {
return (
<div>
<p>Some text</p>
<div>A nested div</div>
</div>
)
}
Cause 8: Async components rendering different content
In Next.js 16 with the App Router, your Server Components can be async. If the data changes between the server render and the client render, you get a mismatch.
This usually happens when you fetch time-sensitive data or data that updates frequently.
// This can break
export default async function Page() {
const views = await getViews()
return <p>This post has {views} views</p>
}
If the view count changes between the server render and hydration, the number is different.
Fix: This is expected behavior for dynamic data. Wrap the dynamic part in a Suspense boundary so React knows it might change.
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<p>Loading views...</p>}>
<Views />
</Suspense>
)
}
async function Views() {
const views = await getViews()
return <p>This post has {views} views</p>
}
Cause 9: CSS that affects layout
If you have CSS that loads asynchronously and changes the layout, the server HTML might have different dimensions or visibility than the client.
This is rare but it happens with CSS-in-JS libraries that inject styles after hydration.
Fix: Make sure your critical CSS is inlined or loaded synchronously. Most CSS-in-JS libraries have a server rendering mode. Use it.
Cause 10: useId mismatch with SSR
React's useId hook generates IDs that should be stable between server and client. But if your component tree is different on the server (for example because of a conditional that depends on data), the IDs can mismatch.
'use client'
import { useId } from 'react'
export default function Form() {
const id = useId()
return <input id={id} />
}
If this component is conditionally rendered and the condition is different on server vs client, the ID sequence shifts.
Fix: Make sure the component tree is the same on both sides. If you need conditional rendering, use a state that starts with the same default on both.
How to debug when you cannot find the cause
Sometimes you read the error and you just cannot figure out which component is causing it. Here is my debugging process.
Step 1: Look at the error in the browser console. It usually tells you which tag type mismatched. For example "server rendered a button but client rendered a div." That narrows it down.
Step 2: If the error says "Text content does not match," look for any dynamic text in your components. Dates, random values, numbers from API calls.
Step 3: Comment out components one by one until the error goes away. Start with the ones that use dates, random values, or browser APIs.
Step 4: Check the page source. Right click, view page source. That is the server HTML. Compare it to what you see in the browser. The difference is your mismatch.
Step 5: If nothing works, open the app in incognito mode. If the error disappears, it is a browser extension.
A quick reference table
| Cause | Fix |
|---|---|
| Dates and times | Fixed timezone or client-only formatting |
| Math.random() | Move to useEffect |
| Browser APIs | useState + useEffect |
| Env var differences | Use NEXT_PUBLIC_ prefix |
| Third-party scripts | next/script with afterInteractive |
| Browser extensions | suppressHydrationWarning on body |
| Invalid HTML nesting | Fix the nesting |
| Async data changes | Suspense boundary |
| CSS layout shift | Inline critical CSS |
| useId mismatch | Keep component tree stable |
Hydration errors are annoying but they are always caused by the same thing: the server and client disagree. Find the disagreement and you find the bug.
I fix Next.js production issues for a living. If you have a hydration error you cannot track down, email me.
