Skip to content
hydration-safety logo

Hydration Safety Skill

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.

SKILL.md

Full skill instructions

Hydration Safety Skill

Overview

Next.js uses Server-Side Rendering (SSR), which means components render twice:

  1. Server-side - Initial HTML generation
  2. 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

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View