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?
- Why Mismatches Happen
- The Error Message
- Components & Actions in Next.js
- Common Hydration Pitfalls
- A Safe Pattern
- Takeaways
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
- Server renders HTML: Static markup is sent to the browser
- Browser displays HTML: Visible but not interactive
- React hydrates: Events and state get attached, making it fully interactive
Let's take a closer look.
Step 1: Server-Rendered HTML
<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
<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
✨ ✨
// 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.