Hydration Patterns
Place state where the server can read it. Hydration mismatches disappear.
The Gap
Server renders HTML. Client attaches event handlers. Between those two moments: dead clicks, wrong content, layout shifts. The root cause is always the same — a component reads client-only state (localStorage, window size) during render, producing HTML the server never saw.
URL searchParams eliminates the gap. React Server Components read the URL before JavaScript executes. No mismatch possible.
Decision Tree
Walk this for every piece of state. The branch determines the pattern.
Does the component need client-only APIs? (localStorage, window, DOM)
├─ YES → Does the initial render need to match server HTML?
│ ├─ YES → URL searchParams (server-readable, no mismatch)
│ └─ NO → useEffect + suppressHydrationWarning (last resort)
└─ NO → Is the state UI-only and ephemeral?
├─ YES → useState (modals, tooltips, hover)
└─ NO → Does it need to survive refresh/sharing/back button?
├─ YES → URL searchParams
└─ NO → useState with initialValue from RSC prop
Each branch maps to a boundary decision. Generic interactive (dropdowns, modals) stays server-safe. Domain-specific state reading localStorage must declare 'use client'. The tree IS the boundary classifier for state.
State Hierarchy
| State Type | Pattern | Example |
|---|---|---|
| Ephemeral UI | useState | dropdown open/closed |
| View/filter mode | URL searchParams | list/kanban toggle |
| User preference | URL + localStorage fallback | theme, density |
| Server-critical | RSC prop from DB/cookie | auth, org context |
| Global client-only | Zustand/Jotai in provider | cart, notifications |
Next.js 15 Checks
Four version-specific gotchas that break silently.
searchParams is a Promise in Next.js 15 server components. Must await it:
// Server component
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ view?: string }>
}) {
const { view } = await searchParams
return <Dashboard view={view ?? 'list'} />
}
useSearchParams() requires a <Suspense> boundary in client components. Without it, the entire page de-opts to client-side rendering:
<Suspense fallback={<ViewToggleSkeleton />}>
<ViewToggle />
</Suspense>
Wrap router.push in useTransition to prevent blocking the UI during navigation:
const [isPending, startTransition] = useTransition()
function setView(next: string) {
startTransition(() => {
router.push(`?view=${next}`, { scroll: false })
})
}
E2E tests: assert on waitForURL before checking content. Synchronize on navigation completion, not hydration timing.
Anti-Patterns
| Don't | Why | Do Instead |
|---|---|---|
Read localStorage during render | Server has no localStorage — guaranteed mismatch | Read in useEffect, or move to searchParams |
typeof window !== 'undefined' guard | Produces different HTML on server vs client — the definition of a mismatch | useEffect for side effects, searchParams for state |
dynamic(() => import(...), { ssr: false }) for state | Kills SSR benefits, adds loading flash | Reserve for truly client-only widgets (maps, editors) |
| Fetch data client-side then hydrate | Double request, loading flash, SEO gap | Fetch in RSC, pass as props |
Context
- State Management — where state lives (URL, API, forms) and why URL-first matters
- Component Communication — passing state across the server/client boundary
- Server Components — the rendering model this page extends
- Component Driven Development — boundary system where hydration patterns live
- Performance — symptom checklist for mismatch warnings and DOM bloat
- Zustand — global client-only state management
Links
- Josh Comeau — The Perils of Rehydration — the canonical explanation of the client/server HTML mismatch problem
- Next.js Docs — Client Components — official boundary rules and
'use client'semantics - React Docs — Suspense — how Suspense boundaries interact with streaming SSR
Questions
How do you make hydration mismatches structurally impossible rather than caught by inspection?
- Which state types belong in the URL versus component state, and what breaks when you choose wrong?
- When does
suppressHydrationWarningindicate a legitimate exception versus a design flaw? - How does the decision tree change when React Server Components move state fetching to the server by default?
- What would a type system look like that enforces state-boundary alignment at compile time?