Resolve Next.js Hydration Mismatch Errors Complete Guide
technology

Resolve Next.js Hydration Mismatch Errors Complete Guide

Hydration mismatch errors breaking your Next.js app? Learn the root causes and 8 proven fixes to eliminate these errors permanently.

2026-02-03
12 min read
Resolve Next.js Hydration Mismatch Errors Complete Guide

I Fixed Next.js Hydration Errors After 3 Days of Debugging: Here's What Worked#

Last month, I deployed my Next.js app to production and everything broke. Not crashed-broke, but worse—the kind of broken where it works perfectly in development but fails silently in production with cryptic hydration errors.

"Text content does not match server-rendered HTML."

"Hydration failed because the initial UI does not match what was rendered on the server."

Sound familiar? I spent three frustrating days tracking down every hydration mismatch in my app. Here's everything I learned, the mistakes I made, and the 8 solutions that actually fixed the problems.

If you're dealing with hydration errors right now, this guide will save you days of debugging. Let's dive in.

This article is part of our comprehensive Deploying Next.js + Supabase to Production guide.

The Problem: What Even Is Hydration?#

Before we fix anything, let me explain what's actually happening. When I first saw these errors, I had no idea what "hydration" meant.

Here's the simple version: Next.js renders your page twice.

  1. Server renders HTML - Next.js generates static HTML on the server (fast initial load)
  2. Browser receives HTML - User sees content immediately
  3. React "hydrates" - JavaScript downloads and makes the page interactive
  4. Mismatch detected - If the HTML from step 1 doesn't match what React generates in step 3, you get hydration errors

The tricky part? Your app works fine in development because dev mode is more forgiving. Production is where these errors bite you.

The Errors That Haunted My Console#

Here are the exact errors I kept seeing (you're probably seeing them too):

bash
# 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 server

Each error took me down a different rabbit hole. Let me show you how I fixed each one.

Fix #1: The Date/Time Nightmare (This Was My Biggest Issue)#

This one cost me an entire day. My blog post timestamps worked perfectly in development but broke in production. Here's why:

The Problem I Had#

My server was in UTC (Vercel's default), but my users were in different timezones. The server rendered one time, the client rendered another. Boom—hydration error.

typescript
// ❌ This is what I had (DON'T DO THIS)
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!
}

I tried three different solutions. Here's what worked:

Quick Fix: suppressHydrationWarning (Use Sparingly)#

This was my first attempt. It works, but it's a band-aid, not a real fix.

typescript
// ✅ 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#

This is what I ended up using for user-specific timestamps. Show a safe fallback on the server, then render the real time on the client.

typescript
'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 (My Final Solution)#

After trying everything, I realized the simplest solution was best: just use UTC everywhere.

typescript
// ✅ This is what I use now: UTC 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. This is what I use for blog post dates now.

Solution 2: Fix localStorage/sessionStorage Access#

Browser APIs don't exist on the server:

What I Did Wrong#

I tried to read localStorage during render. The server doesn't have localStorage, so it crashed.

typescript
// ❌ This crashed my server (DON'T DO THIS)
export function ThemeToggle() {
  const theme = localStorage.getItem('theme') || 'light'
  // ❌ ReferenceError: localStorage is not defined (server)
  
  return <button>{theme}</button>
}

The error message was clear, but the fix wasn't obvious at first.

How I Fixed It#

Move all localStorage access into useEffect. It only runs on the client, never on the server.

typescript
'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#

After fixing this in three different components, I made a custom hook. Now I use this everywhere:

typescript
// 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>
  )
}

This hook saved me hours. Copy it, use it, thank me later.

Fix #3: Random Values (The Sneaky One)#

This bug was subtle. I was generating random IDs for accessibility attributes, and they kept changing between server and client.

The Mistake#

typescript
// ❌ 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.

typescript
'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>
}

This is the cleanest solution. React handles the complexity for you.

Solution: Pass ID as Prop#

typescript
// 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 (Gotcha!)#

This one was frustrating because it seemed like the right approach. Checking if window exists before using it, right? Wrong.

What I Did Wrong#

typescript
// ❌ 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 (Duh!)#

I was overthinking it. CSS already handles this perfectly.

typescript
// ✅ 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#

typescript
'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 (The Chart.js Disaster)#

I added a chart library and suddenly everything broke. Turns out, many chart libraries assume they're running in a browser.

The Problem#

typescript
// ❌ This crashed my build
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 has a built-in solution for this.

typescript
import dynamic from 'next/dynamic'

// ✅ This saved me: 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#

typescript
// 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 (The Validator Caught This)#

This was embarrassing. I had a <p> tag containing a <div>. Browsers auto-correct invalid HTML, which causes hydration mismatches.

What I Did Wrong#

typescript
// ❌ I had this in my blog post component
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>
  )
}

Browsers automatically fix invalid HTML. Server sends <p><div>, browser corrects it to <p></p><div>, React sees a mismatch.

The Fix: Valid HTML#

I ran my HTML through a validator and fixed all the nesting issues.

typescript
// ✅ 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>
  )
}

Rules I learned:

  • <p> can only contain inline elements (no <div>, <section>, etc.)
  • <button> cannot contain other interactive elements
  • <a> cannot contain other <a> tags
  • Use the HTML validator in your browser dev tools

Fix #7: Browser Extensions (The Sneakiest Bug)#

This one drove me crazy. My app worked fine on my machine but broke on my coworker's. Turns out, browser extensions were injecting HTML.

The Problem#

Extensions like Grammarly, LastPass, React DevTools, and ad blockers inject HTML into your page. Server doesn't know about this, client has extra elements. Hydration mismatch.

I tested in incognito mode (no extensions) and everything worked. That's when I realized.

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.

typescript
// 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#

typescript
'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 (How I Found Everything)#

After fixing the obvious issues, I still had random hydration errors. Here's how I tracked them down.

Step 1: Enable React Strict Mode#

This makes React show you exactly where hydration errors occur.

javascript
// next.config.mjs
const nextConfig = {
  reactStrictMode: true,  // Shows detailed hydration errors
}

Strict Mode double-renders components in development, which helps catch hydration issues early.

Step 2: Use React DevTools#

Install the React DevTools browser extension and enable "Highlight updates when components render." Hydration mismatches will flash red.

This helped me find a component that was re-rendering with different content after hydration.

Step 3: Add Error Boundaries#

I wrapped suspicious components in error boundaries to catch and log hydration errors.

typescript
'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) {
    // This helped me find which component was causing issues
    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:

typescript
<HydrationErrorBoundary>
  <SuspiciousComponent />
</HydrationErrorBoundary>

Mistakes I Made (So You Don't Have To)#

Here are the dumb things I did while debugging:

  1. Ignoring warnings in development - I thought "it's just a warning, I'll fix it later." Those warnings became production bugs.

  2. Using suppressHydrationWarning everywhere - I got frustrated and started adding it to everything. Don't do this. Fix the root cause.

  3. Not testing in production mode - npm run dev is forgiving. npm run build && npm run start shows the real errors.

  4. Testing only on my machine - Different timezones, different browsers, different extensions all cause different hydration errors.

  5. Accessing window during render - I kept forgetting that render happens on the server too. Use useEffect for browser APIs.

  6. Not checking HTML validity - Invalid HTML gets auto-corrected by browsers, causing hydration mismatches.

  7. Assuming the error message was wrong - React's error messages are actually pretty good. If it says there's a mismatch, there is.

FAQ#

Are hydration warnings serious?#

Yes! They indicate your app's HTML doesn't match between server and client, which can cause bugs, poor performance, and SEO issues.

Can I just suppress all warnings?#

No. suppressHydrationWarning should only be used for unavoidable cases like timestamps. Fix the root cause instead.

Why do hydration errors only appear sometimes?#

They depend on timing, browser extensions, and environment differences. Test thoroughly in production-like environments.

Do hydration errors affect SEO?#

Yes. Search engines see the server-rendered HTML, but users might see different content after hydration, confusing search engines.

How do I test for hydration errors?#

Enable React Strict Mode, test with different timezones, disable browser extensions, and test on different devices.

What I Learned (The Hard Way)#

After three days of debugging, here's what I wish someone had told me:

  1. Hydration errors are production bugs - They might show as warnings in dev, but they break things in production. Fix them immediately.

  2. The server and client are different environments - Server has no window, no localStorage, no browser APIs. Client has all of these. Your code needs to handle both.

  3. Time is the enemy - Anything time-related (Date.now(), timestamps, "time ago" text) will cause hydration errors unless you handle it carefully.

  4. Test in production mode - npm run dev hides problems. Always test with npm run build && npm run start before deploying.

  5. Browser extensions are sneaky - Test in incognito mode. Extensions inject HTML that causes hydration errors.

  6. suppressHydrationWarning is a last resort - It's not a fix, it's a band-aid. Only use it for unavoidable cases like timestamps on html/body tags.

  7. React's error messages are good - When React says there's a mismatch, believe it. The error message usually tells you exactly what's different.

Conclusion: You Can Fix This#

Hydration errors are frustrating, but they're fixable. I spent three days debugging mine, but now my app is rock solid.

The key insight: server and client must render identical HTML. Any difference—even a single character—causes a hydration error.

Most hydration errors come from:

  • Date/time formatting (use UTC or useEffect)
  • Browser APIs (wrap in useEffect)
  • Random values (use React's useId)
  • Invalid HTML (validate your markup)
  • Third-party libraries (use dynamic imports)

Start with the checklist above. Enable React Strict Mode. Test in production mode. Test in incognito. You'll find the issues.

And remember: every hydration error you fix makes your app faster, more reliable, and better for SEO. It's worth the effort.

If this guide helped you, share it with someone else fighting hydration errors. We've all been there.

Now go fix those hydration errors. You got this.

My Testing Checklist (Copy This)#

After fixing everything, I created this checklist. I run through it before every deploy now:

□ No window/document access outside useEffect
□ No Math.random() or Date.now() in render
□ All dates use UTC or suppressHydrationWarning
□ No localStorage/sessionStorage without useEffect
□ All conditional rendering is consistent
□ HTML validated (no <p> containing <div>, etc.)
□ Third-party libraries use dynamic import with ssr: false
□ React Strict Mode enabled
□ Tested in production build (npm run build && npm run start)
□ Tested in incognito mode (no extensions)
□ Tested in different timezones
□ Tested on mobile and desktop

Print this out. Seriously. It'll save you hours.

Common Mistakes#

  • Mistake #1: Using suppressHydrationWarning everywhere - Only use for unavoidable cases like timestamps

  • Mistake #2: Not testing production builds - Hydration errors often only appear in production

  • Mistake #3: Ignoring timezone differences - Server and client may be in different timezones

  • Mistake #4: Passing functions as props - Functions aren't serializable

  • Mistake #5: Not using useEffect for client-only code - Browser APIs must be accessed in useEffect

FAQ#

Are hydration warnings actually serious?#

Yes, they're serious. I ignored them for a week thinking "it's just a warning." Then users started reporting bugs—buttons not working, content flickering, wrong data showing up. Hydration warnings indicate real bugs that affect user experience, performance, and SEO. Fix them immediately.

Can I just add suppressHydrationWarning everywhere?#

Please don't. I tried this out of frustration and it made everything worse. suppressHydrationWarning hides the warning but doesn't fix the problem. Your app will still have bugs, you just won't see them in the console. Only use it for unavoidable cases like timestamps or browser extension interference on html/body tags.

Why do hydration errors only appear in production?#

Development mode is more forgiving. Next.js dev server does client-side rendering, which hides hydration issues. Production uses server-side rendering, which exposes them. Always test with npm run build && npm run start before deploying.

Do hydration errors affect SEO?#

Yes. Search engines see the server-rendered HTML, but users see the client-rendered version after hydration. If they're different, search engines index one thing and users see another. Google doesn't like this. Fix your hydration errors for better SEO.

How do I test for hydration errors before deploying?#

Here's my process:

  1. Enable React Strict Mode in next.config.js
  2. Run npm run build && npm run start (not npm run dev)
  3. Test in incognito mode (no browser extensions)
  4. Test in different timezones (change your system timezone)
  5. Test on mobile and desktop
  6. Check the console for any warnings

If you pass all these tests, you're good to deploy.

Conclusion#

Hydration mismatch errors are usually caused by inconsistent rendering between server and client. The most common causes are date/time differences, browser API access, and random values.

The key to preventing these errors is ensuring your server and client render identical HTML. Use useEffect for client-only code, suppressHydrationWarning only for unavoidable cases, and always test your production builds.

With these solutions in place, you'll eliminate hydration errors and have a more stable, performant Next.js application.

Frequently Asked Questions

|

Have more questions? Contact us