Skip to main content

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 TypePatternExample
Ephemeral UIuseStatedropdown open/closed
View/filter modeURL searchParamslist/kanban toggle
User preferenceURL + localStorage fallbacktheme, density
Server-criticalRSC prop from DB/cookieauth, org context
Global client-onlyZustand/Jotai in providercart, 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'tWhyDo Instead
Read localStorage during renderServer has no localStorage — guaranteed mismatchRead in useEffect, or move to searchParams
typeof window !== 'undefined' guardProduces different HTML on server vs client — the definition of a mismatchuseEffect for side effects, searchParams for state
dynamic(() => import(...), { ssr: false }) for stateKills SSR benefits, adds loading flashReserve for truly client-only widgets (maps, editors)
Fetch data client-side then hydrateDouble request, loading flash, SEO gapFetch in RSC, pass as props

Context

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 suppressHydrationWarning indicate 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?