Claude-skill-registry 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.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/hydration-safety" ~/.claude/skills/majiayu000-claude-skill-registry-hydration-safety-5d78df && rm -rf "$T"
manifest:
skills/data/hydration-safety/SKILL.mdsource content
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