hydration-safety
Next.js SSR hydration patterns and best practices. Use when fixing hydration mismatches, implementing client-only features, or working with localStorage/browser APIs.
When & Why to Use This Skill
This Claude skill provides comprehensive patterns and best practices for managing Next.js SSR hydration. It helps developers prevent and resolve 'Text content does not match server-rendered HTML' errors by implementing the isMounted pattern, handling browser APIs safely, and ensuring consistent rendering between server and client environments.
Use Cases
- Fixing hydration mismatch errors when accessing browser-only APIs like localStorage, sessionStorage, or the window object.
- Implementing client-only components or UI features that should only be rendered after the initial hydration to avoid flickering.
- Ensuring consistent rendering for dynamic or non-deterministic data, such as random values, unique IDs, or localized date/time strings.
- Debugging 'Hydration failed' warnings by identifying structural differences between server-rendered HTML and the initial client-side DOM.
- Optimizing E2E testing stability in Playwright or Cypress by implementing proper synchronization patterns that wait for React hydration to complete.
| name | hydration-safety |
|---|---|
| description | Next.js SSR hydration patterns and best practices. Use when fixing hydration mismatches, implementing client-only features, or working with localStorage/browser APIs. |
Hydration Safety Skill
Overview
Next.js uses Server-Side Rendering (SSR), which means components render twice:
- Server-side - Initial HTML generation
- Client-side - React hydration with JavaScript
Mismatches between these two renders cause hydration errors. This skill covers patterns to prevent and fix these issues.
The Core Problem
// ❌ WRONG - Causes hydration mismatch
function Component() {
const user = localStorage.getItem("user"); // localStorage not available on server
return <div>Welcome {user}</div>;
}
```typescript
**Error:** "Text content does not match server-rendered HTML"
**Why:** Server renders `<div>Welcome </div>`, client tries to render `<div>Welcome John</div>`
## The isMounted Pattern (CRITICAL)
This is the **standard solution** for hydration-safe components:
```typescript
"use client";
import { useState, useEffect } from "react";
function Component() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Return null or skeleton during SSR
if (!isMounted) {
return null; // or <Skeleton />
}
// Only runs on client after hydration
const user = localStorage.getItem("user");
return <div>Welcome {user}</div>;
}
```typescript
### How It Works
1. **Server-side:** `isMounted = false`, component returns `null`
2. **Client-side (first render):** `isMounted = false`, still returns `null`
3. **Client-side (after useEffect):** `isMounted = true`, renders with browser APIs
4. **Result:** Server and first client render match perfectly
## Common Hydration Triggers
### 1. Browser APIs
❌ **Causes Hydration Errors:**
- `localStorage`
- `sessionStorage`
- `window`
- `document`
- `navigator`
- Date/time without consistent timezone
✅ **Solution:** Use isMounted pattern
### 2. Random Values
```typescript
// ❌ WRONG
function Component() {
const id = Math.random(); // Different on server vs client
return <div id={id}>Content</div>;
}
// ✅ CORRECT
function Component() {
const [id, setId] = useState<string>();
useEffect(() => {
setId(String(Math.random()));
}, []);
return <div id={id}>Content</div>;
}
```typescript
### 3. Date/Time
```typescript
// ❌ WRONG
function Clock() {
return <div>{new Date().toLocaleTimeString()}</div>;
}
// ✅ CORRECT
"use client";
function Clock() {
const [time, setTime] = useState<string>();
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(interval);
}, []);
if (!time) return <div>--:--:--</div>; // Placeholder
return <div>{time}</div>;
}
```typescript
## Implementation Patterns
### Pattern 1: Null During SSR
```typescript
"use client";
function Component() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) return null;
return <div>{/* Browser-dependent content */}</div>;
}
```typescript
**Use when:** Component doesn't need to show during SSR
### Pattern 2: Skeleton During SSR
```typescript
"use client";
function Component() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="animate-pulse bg-surf-1 h-10 w-32" />;
}
return <div>{/* Actual content */}</div>;
}
```typescript
**Use when:** Need loading state visible during SSR
### Pattern 3: Safe Defaults
```typescript
"use client";
function Component() {
const [data, setData] = useState<string>("default");
useEffect(() => {
const stored = localStorage.getItem("key");
if (stored) setData(stored);
}, []);
return <div>{data}</div>;
}
```typescript
**Use when:** Can show default value during SSR
## Project-Specific Patterns
### Sidebar Assistant (Example from codebase)
Location: `src/components/chat/chat-sidebar.tsx`
```typescript
"use client";
export function ChatSidebar() {
const [isMounted, setIsMounted] = useState(false);
const { isPinned, width } = useChatSidebar();
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null; // Sidebar hidden during SSR
}
return (
<div style={{ width: `${width}px` }}>
{/* Sidebar content */}
</div>
);
}
```typescript
### Global Chat Button
Location: `src/components/global-chat-button.tsx`
```typescript
"use client";
export function GlobalChatButton() {
const { isOpen } = useChatSidebar();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, [isOpen]); // Added isOpen to dependency array
// Early return AFTER all hooks
if (!isMounted || isOpen) {
return null;
}
return <button>Open Chat</button>;
}
```typescript
**Note:** All hooks must be called before conditional returns!
## Testing Hydration Safety
### Unit Tests
```typescript
import { render, screen } from "@testing-library/react";
import { act } from "react";
describe("Hydrated Component", () => {
it("should not show content during SSR", () => {
render(<Component />);
expect(screen.queryByTestId("hydrated-content")).not.toBeInTheDocument();
});
it("should show content after mount", async () => {
render(<Component />);
// Wait for useEffect
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(screen.getByTestId("hydrated-content")).toBeInTheDocument();
});
});
```typescript
### E2E Tests (Playwright)
```typescript
import { test, expect } from "@playwright/test";
test("should handle hydration correctly", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
// Wait for React hydration to complete
await page.waitForSelector('[data-testid="hydrated-component"]', {
state: "attached",
timeout: 10000,
});
// Wait for loading spinner to disappear (if present)
await page.waitForSelector(".animate-spin", {
state: "detached",
timeout: 10000,
});
// Additional stabilization time
await page.waitForTimeout(500);
// Now safe to interact
await page.click('[data-testid="interactive-element"]');
});
```typescript
### Why E2E Waits Are Critical
Without waits, E2E tests run on intermediate DOM states:
```typescript
// ❌ WRONG - Runs too early
test("wrong timing", async ({ page }) => {
await page.goto("/");
await page.click("button"); // May not be hydrated yet!
});
// ✅ CORRECT - Waits for hydration
test("correct timing", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" });
await page.waitForSelector('[data-testid="ready"]');
await page.waitForTimeout(500); // DOM stabilization
await page.click("button"); // Now safe!
});
```typescript
## Debugging Hydration Errors
### Error Messages
**"Text content does not match server-rendered HTML"**
- Cause: Different text rendered on server vs client
- Solution: Use isMounted pattern or ensure consistent data
**"Hydration failed because the initial UI does not match"**
- Cause: Different structure rendered on server vs client
- Solution: Make server and client render the same initially
**"There was an error while hydrating"**
- Cause: Component error during hydration
- Solution: Check browser console for specific error
### Debugging Steps
1. **Check Browser Console** - Specific error details appear here
2. **Inspect Network Tab** - Compare SSR HTML vs hydrated DOM
3. **Use React DevTools** - Highlight hydration mismatches
4. **Add data-testid After Mount** - Verify component is hydrated
```typescript
return (
<div data-testid={isMounted ? "hydrated" : undefined}>
Content
</div>
);
```typescript
5. **Check localStorage/sessionStorage Access** - Most common cause
## React Hooks Rules with Hydration
### ⚠️ Critical: Hooks Before Returns
```typescript
// ❌ WRONG - Hook after conditional return
function Component() {
const { isOpen } = useContext();
if (!isOpen) return null; // Early return
useEffect(() => { // ❌ Hook after return!
// ...
}, []);
}
// ✅ CORRECT - All hooks before returns
function Component() {
const { isOpen } = useContext();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => { // ✓ Hook before return
setIsMounted(true);
}, []);
if (!isOpen || !isMounted) return null; // After all hooks
}
```typescript
## Common Mistakes
### Mistake 1: Checking window Directly
```typescript
// ❌ WRONG
function Component() {
if (typeof window === "undefined") return null;
return <div>{localStorage.getItem("key")}</div>;
}
```typescript
**Problem:** First client render still won't match server
### Mistake 2: Forgetting "use client"
```typescript
// ❌ WRONG - Missing directive
import { useState, useEffect } from "react";
function Component() {
const [mounted, setMounted] = useState(false);
// ...
}
```typescript
**Problem:** Server components can't use hooks
### Mistake 3: Using useLayoutEffect
```typescript
// ❌ WRONG - Causes SSR warnings
useLayoutEffect(() => {
setIsMounted(true);
}, []);
// ✅ CORRECT - Use useEffect for hydration
useEffect(() => {
setIsMounted(true);
}, []);
```typescript
## Checklist for Hydration-Safe Components
- [ ] Add "use client" directive if using hooks
- [ ] All hooks called before conditional returns
- [ ] Use isMounted pattern for browser APIs
- [ ] Return consistent structure during SSR and first client render
- [ ] Add data-testid after mount for E2E tests
- [ ] Test component in both SSR and client contexts
- [ ] Verify no hydration errors in browser console
- [ ] E2E tests wait for hydration before interactions
## Quick Reference
```typescript
// Standard isMounted pattern
const [isMounted, setIsMounted] = useState(false);
useEffect(() => { setIsMounted(true); }, []);
if (!isMounted) return null;
// E2E wait pattern
await page.goto("/", { waitUntil: "networkidle" });
await page.waitForSelector('[data-testid="ready"]');
await page.waitForTimeout(500);
// Debug in browser
// Check: document.documentElement.dataset.reactHydrated
```typescript
## Related Files
- `src/components/chat/chat-sidebar.tsx` - Production example
- `src/components/global-chat-button.tsx` - Production example
- `e2e/a11y.spec.ts` - E2E hydration patterns
- `src/lib/chat-sidebar-context.tsx` - Context with hydration safety