Understanding Hydration Errors in Next.js

7 min read

If you've worked with Next.js, you've probably hit a hydration error. The error message is not exactly helpful at first glance.

Understanding hydration errors is actually a great way to learn how React hydration works, and how Next.js splits logic between server and client.

Let's break it down.

Table of Contents

What Is Hydration?

Hydration is when React "wakes up" static HTML and makes it interactive.

Your server sends HTML to the browser first, but it's just markup — no clicks work, no state updates happen. It's like a screenshot of your app.

React then hydrates this HTML by attaching event listeners and state. If the server HTML doesn't match what React expects, you get a hydration error. ⚠️

The Process

  1. Server renders HTML: Static markup is sent to the browser
  2. Browser displays HTML: Visible but not interactive
  3. React hydrates: Events and state get attached, making it fully interactive

Let's take a closer look.

Step 1: Server-Rendered HTML

STATIC
<div>Count: 0</div>
<button>Increment</button>
// No JavaScript attached

HTML is generated on the server and sent to the browser. The page is visible but not yet interactive. 💤

Step 2: Before Hydration

LOADING
<div>Count: 0</div>
<button>Increment</button>
// React bundle loading...

React hasn't taken control yet. HTML is rendered, but event handlers and state management are not connected. ⏳

Step 3: After Hydration

INTERACTIVE
<div>Count: 0</div>

// onClick, useState active

React attaches event listeners and rehydrates the app. Now it's a fully interactive SPA with working state management. 🕹️

Why Mismatches Happen

React doesn't just "tweak" the server HTML. Instead, it compares what the server generated with what it would generate itself.

The core issue: dynamic values

<div>{Date.now()}</div>
  • Server execution: 1694304000000
  • Client execution: 1757452742467

React expects "same code = same result." When it finds differences, it throws an error saying "Something's broken!"

Other common causes:

  • Browser-only APIs (window, localStorage)
  • Random values (Math.random())
  • User-specific data that differs between server and client
  • Conditional rendering based on client state

The Error Message

Here's what you'll see:

Error: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties...

Translation: "What you rendered on the server isn't what React expected on the client."

Components & Actions in Next.js

Understanding Next.js component types helps prevent hydration errors.

Server Components ⚙️

Server Components are the default in Next.js 13+.

  • Run only on the server
  • Can fetch data, talk to databases directly
  • No useState, useEffect, or browser APIs
  • Not included in JavaScript bundle

Use for: Static content, data fetching, layouts

Client Components 💻

  • Run on both server (for SSR) and browser
  • Need "use client" directive at the top
  • Support hooks and browser APIs (but only after hydration)
  • Required for any interactivity
  • Included in JavaScript bundle

Use for: Interactive UI elements, state management, event handlers

Server Actions ⚡

Server Actions are functions (not components) that always run on the server.

  • Need "use server" directive
  • Work seamlessly with async/await
  • Callable from both client and server components

Use for: Database operations, form handling, server-side logic

Common Hydration Pitfalls

Here are the most common mistakes and their solutions.

Pitfall 1: Using Browser APIs in Server Components

Problem:

Accessing browser-only APIs like window or localStorage during rendering causes server-client mismatches. Server Components run on the server where these APIs don't exist.

function BadComponent() {
  const theme = typeof window !== 'undefined' ? localStorage.getItem('theme') : 'light';
  return <div className={theme}>Content</div>;
}

Solution:

Convert to a Client Component to handle client-only logic after hydration.

'use client';
import { useState, useEffect } from 'react';
 
function GoodComponent() {
  const [theme, setTheme] = useState('light');
 
  useEffect(() => {
    // This runs only on the client after hydration
    setTheme(localStorage.getItem('theme') || 'light');
  }, []);
 
  return <div className={theme}>Content</div>;
  // Server renders: <div className="light">Content</div>
  // Client hydrates: same HTML, then useEffect updates theme if different
}

Pitfall 2: Time-Dependent Values

Problem:

Date(), Math.random(), or other time-sensitive functions return different values on server vs client.

function BadTimestamp() {
  return <div>Generated: {new Date().toLocaleString()}</div>;
}

Solution:

Convert to a Client Component and use client-only rendering for dynamic timestamps.

'use client';
import { useState, useEffect } from 'react';
 
function GoodTimestamp() {
  const [time, setTime] = useState('Loading...');
 
  useEffect(() => {
    setTime(new Date().toLocaleString());
  }, []);
 
  return <div>Current time: {time}</div>;
  // Server renders: <div>Current time: Loading...</div>
  // Client hydrates: same HTML, then useEffect updates to actual time
}

Pitfall 3: Conditional Rendering Based on Client State

Problem:

Server can't access user authentication, preferences, or browser state.

function BadAuthCheck() {
  const isLoggedIn = checkAuthStatus(); // Server doesn't know the status
  return isLoggedIn ? <Dashboard /> : <LoginForm />;
}

Solution:

Convert to a Client Component and and handle authentication state properly with loading states.

'use client';
import { useState, useEffect } from 'react';
 
function GoodAuthCheck() {
  const [authState, setAuthState] = useState({ isLoggedIn: false, loading: true });
 
  useEffect(() => {
    checkAuthStatus().then(isLoggedIn => {
      setAuthState({ isLoggedIn, loading: false });  // Update isLoggedIn
    });
  }, []); // Runs after client hydration completes
 
  if (authState.loading) return <div>Loading...</div>;
  return authState.isLoggedIn ? <Dashboard /> : <LoginForm />;
  // Server renders: <div>Loading...</div>
  // Client hydrates: same HTML, then useEffect updates actual auth state
}

Pitfall 4: External Content Modification

Problem:

Browser extensions, iOS auto-detection, or third-party scripts modify the DOM after server rendering, causing hydration mismatches.

Solution:

Use suppressHydrationWarning strategically depending on the scope of external modifications.

For global modifications (browser extensions, theme changes):

<html lang="en" suppressHydrationWarning>
  <body>
    {children}
  </body>
</html>

For specific content that gets modified:

function SafeContent() {
  return (
    <div suppressHydrationWarning>
      Call us: 1-800-555-0123
    </div>
  );
}

A Safe Pattern

Here's how to structure your components to avoid hydration errors:

Server Component: Pure Data Fetching

ProductPage() only fetches data and renders stable HTML.

// app/products/[id]/page.tsx
import { fetchProduct } from '@/lib/db';
import AddToCart from '@/components/AddToCart';
 
export default async function ProductPage({ params }) {
  const product = await fetchProduct(params.id);
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCart productId={product.id} />
    </div>
  );
}

Client Component: Pure Interactivity

AddToCart() handles user interactions in the browser.

// app/components/AddToCart.tsx
'use client';
import { useState } from 'react';
import { addToCart } from '@/actions/cart';
 
export default function AddToCart({ productId }) {
  const [qty, setQty] = useState(1);
  const [loading, setLoading] = useState(false);
 
  const handleSubmit = async () => {
    setLoading(true);
    await addToCart(productId, qty);
    setLoading(false);
  };
 
  return (
    <div>
      <button onClick={() => setQty(q => q + 1)}>
        Quantity: {qty}
      </button>
      <button onClick={handleSubmit} disabled={loading}>
        {loading ? 'Adding...' : 'Add to Cart'}
      </button>
    </div>
  );
}

Server Action: Pure Server Logic

addToCart() runs exclusively on the server.

// app/actions/cart.ts
'use server';
 
export async function addToCart(productId: string, qty: number) {
  // This runs on the server, never affects hydration
  await db.cart.create({
    productId,
    quantity: qty,
    userId: await getCurrentUserId()
  });
}

By separating data fetching (server), interactivity (client), and server-side mutations (actions), you can avoid the mismatches that cause hydration errors.

Takeaways

  • Hydration = turning static HTML into an interactive React app
  • Errors happen when server HTML ≠ client expectations
  • Server Components: data fetching + stable rendering, no browser APIs
  • Client Components: interactivity + state, need "use client"
  • Server Actions: server-only functions for mutations

Once you understand hydration, Next.js rendering patterns become much clearer. The key is thinking about where your code runs and when it produces different results.