Skip to content

DOMAIN:FRONTEND:SSR_HYDRATION

OWNER: floris (Team Alpha), floor (Team Beta)
UPDATED: 2026-03-24
SCOPE: all client projects using Next.js App Router
SEVERITY_CONTEXT: hydration mismatches are production bugs — they cause UI flicker, broken interactivity, and SEO degradation


HOW_SSR_WORKS

STEP_1: server receives request, renders React tree to HTML string.
STEP_2: HTML is sent to browser. User sees content immediately (non-interactive).
STEP_3: browser downloads JavaScript bundle.
STEP_4: React "hydrates" — attaches event listeners to existing HTML, makes it interactive.
STEP_5: if server HTML does not match what client React expects, HYDRATION MISMATCH occurs.

KEY_INSIGHT: during hydration, React does NOT re-render from scratch. It reuses server HTML and attaches handlers. If server output differs from client output, React cannot reconcile and either throws an error or silently produces broken UI.


HYDRATION_MISMATCH_CAUSES

CAUSE_1: DATE_AND_TIME

SYMPTOM: "Text content does not match server-rendered HTML"
REASON: new Date() produces different values on server vs client (different timezones, different milliseconds).

// BAD: different output on server vs client
export default function Header() {
  return <p>Last updated: {new Date().toLocaleString()}</p>;
}

FIX: render dates on client only, or pass server-computed date as data.

// FIX 1: client-only rendering with suppressHydrationWarning
export default function Header() {
  return <p suppressHydrationWarning>Last updated: {new Date().toLocaleString()}</p>;
}

// FIX 2: compute on server, pass as prop (no mismatch because value is fixed)
// app/page.tsx (Server Component)
export default function Page() {
  const lastUpdated = new Date().toISOString();
  return <Header lastUpdated={lastUpdated} />;
}

// FIX 3: render on client only
"use client";
import { useState, useEffect } from "react";

export function ClientDate() {
  const [date, setDate] = useState<string | null>(null);
  useEffect(() => { setDate(new Date().toLocaleString()); }, []);
  if (!date) return <span className="h-4 w-20 animate-pulse bg-muted rounded" />;
  return <span>{date}</span>;
}

CAUSE_2: MATH_RANDOM

SYMPTOM: different content on refresh, hydration mismatch.
REASON: Math.random() produces different values on server and client.

// BAD
const id = `input-${Math.random().toString(36).slice(2)}`;

FIX: use React's useId() hook for stable IDs across server/client.

// GOOD: useId generates same ID on server and client
"use client";
import { useId } from "react";

export function FormField({ label }: { label: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </div>
  );
}

CAUSE_3: WINDOW_DOCUMENT_LOCALSTORAGE

SYMPTOM: "window is not defined" or content mismatch based on client state.
REASON: server has no browser APIs. Code that reads window, document, or localStorage during render produces different output.

// BAD: reads localStorage during render
"use client";
export function Greeting() {
  const name = localStorage.getItem("userName") || "Guest"; // CRASHES on server
  return <h1>Hello, {name}</h1>;
}

FIX: defer browser API access to useEffect.

// GOOD: mounted pattern
"use client";
import { useState, useEffect } from "react";

export function Greeting() {
  const [name, setName] = useState("Guest");

  useEffect(() => {
    const stored = localStorage.getItem("userName");
    if (stored) setName(stored);
  }, []);

  return <h1>Hello, {name}</h1>;
}

CAUSE_4: INVALID_HTML_NESTING

SYMPTOM: browser auto-corrects HTML, causing mismatch with React's expected tree.
REASON: browsers enforce HTML nesting rules. If server sends <div> inside <p>, browser auto-closes <p> first, producing different DOM than React expects.

FORBIDDEN_NESTINGS:
- <div> inside <p>
- <p> inside <p>
- <h1> inside <p>
- <ul> inside <p>
- interactive elements inside <button> or <a>

// BAD: div inside p causes browser to split the p tag
<p>Some text <div>This breaks</div> more text</p>

// GOOD: use span or restructure
<div>
  <p>Some text</p>
  <div>This is separate</div>
  <p>More text</p>
</div>

CAUSE_5: CONDITIONAL_RENDERING_ON_CLIENT_STATE

SYMPTOM: server renders one thing, client renders another based on screen size, theme, feature flags.

// BAD: server has no window.innerWidth
"use client";
export function Layout() {
  const isMobile = window.innerWidth < 768; // CRASHES or mismatches
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

FIX: use CSS for responsive layout (no JS needed), or use mounted pattern.

// GOOD: CSS handles responsive, no hydration issue
export function Layout() {
  return (
    <>
      <MobileNav className="block md:hidden" />
      <DesktopNav className="hidden md:block" />
    </>
  );
}

CAUSE_6: BROWSER_EXTENSIONS

SYMPTOM: hydration mismatch only in development, not production. Extra attributes on HTML.
REASON: browser extensions (ColorZilla, Grammarly, password managers) inject attributes like cz-shortcut-listen="true".
FIX: test in incognito mode. This is not a real bug — ignore if extension-only.

CAUSE_7: THEME_PROVIDER

SYMPTOM: flash of wrong theme, hydration mismatch on <html class="dark">.
REASON: server does not know user's theme preference. Client applies theme class after hydration.
FIX: suppressHydrationWarning on <html> tag + next-themes handles the rest.

<html lang="en" suppressHydrationWarning>

STREAMING_SSR

HOW_IT_WORKS

TRADITIONAL_SSR: server fetches ALL data, renders ALL HTML, sends complete page. Slow if any fetch is slow.
STREAMING_SSR: server sends HTML in chunks. Fast parts arrive first. Slow parts stream in as they resolve.

MECHANISM: HTTP chunked transfer encoding. React renders Suspense fallbacks first, then streams resolved content as <script> tags that swap fallback with real content.

IMPLEMENTATION

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function Dashboard() {
  return (
    <main>
      {/* This renders immediately */}
      <h1>Dashboard</h1>

      {/* These stream independently */}
      <Suspense fallback={<div className="h-32 animate-pulse bg-muted rounded-lg" />}>
        <SlowAnalytics /> {/* 2s fetch — streams in when ready */}
      </Suspense>

      <Suspense fallback={<div className="h-48 animate-pulse bg-muted rounded-lg" />}>
        <SlowerReportTable /> {/* 5s fetch — streams in independently */}
      </Suspense>
    </main>
  );
}

SUSPENSE_BOUNDARIES_STRATEGY

RULE: one Suspense boundary per independent data source.
RULE: outer layout should never be inside a Suspense boundary.
RULE: fallback should match the dimensions of the real content (prevent CLS).

GOOD_FALLBACK: skeleton that matches the component shape.
BAD_FALLBACK: generic spinner (causes layout shift when content loads).

// GOOD: skeleton matches final layout
function ChartSkeleton() {
  return (
    <div className="space-y-2">
      <div className="h-4 w-32 animate-pulse rounded bg-muted" />
      <div className="h-64 animate-pulse rounded-lg bg-muted" />
    </div>
  );
}

// BAD: spinner that doesn't reserve space
function BadFallback() {
  return <Spinner />; // No height reserved — CLS when content loads
}

SELECTIVE_HYDRATION

HOW_IT_WORKS

TRADITIONAL_HYDRATION: React hydrates entire page in one pass. Nothing is interactive until all JS executes.
SELECTIVE_HYDRATION: React hydrates components independently. Interactive components hydrate first. Low-priority components wait.

AUTOMATIC_IN_NEXTJS: when using Suspense boundaries, React automatically enables selective hydration.
PRIORITY: if user clicks/taps on a component that is still hydrating, React prioritizes hydrating that component immediately.

OPTIMIZATION

STRATEGY: keep above-the-fold interactive elements in small, early-loading Client Components.
STRATEGY: defer heavy below-the-fold components with next/dynamic.

import dynamic from "next/dynamic";

// Loads and hydrates only when scrolled into view or needed
const HeavyChart = dynamic(() => import("@/components/heavy-chart"), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Skip SSR entirely for client-only components
});

WHEN_SSR_FALSE: component uses browser-only APIs (canvas, WebGL), is below the fold, or has no SEO value.
AVOID_SSR_FALSE: for content that needs to be indexed by search engines or visible in the initial HTML.


PROGRESSIVE_ENHANCEMENT

PRINCIPLE: pages work without JavaScript. JS enhances the experience.
MECHANISM: Server Components render full HTML. Forms use Server Actions (work via native form submission).

// This form works with and without JavaScript:
// Without JS: native form POST, full page refresh, server processes action
// With JS: React intercepts, no page refresh, instant feedback
<form action={createProject}>
  <input name="name" required />
  <button type="submit">Create</button>
</form>

BENEFIT: users on slow connections see content immediately. Interactive parts enhance progressively.
BENEFIT: SEO crawlers see complete HTML without JS execution.


EDGE_RUNTIME_VS_NODE_RUNTIME

DECISION_TREE

Does the route need...
  fs, crypto (full), child_process, native modules?    → Node.js runtime
  Database connection via TCP?                          → Node.js runtime
  Heavy computation (>50ms)?                            → Node.js runtime
  Only Web Standard APIs (fetch, URL, TextEncoder)?     → Edge runtime OK
  Global distribution / minimal latency?                → Edge runtime
  Simple auth check / redirect logic?                   → Edge runtime (middleware)

NODE_RUNTIME

DEFAULT for all routes.
CAPABILITIES: full Node.js API, all npm packages, database connections, file system access.
LATENCY: depends on server location (single region).

EDGE_RUNTIME

OPT_IN: export const runtime = "edge" in route file.
CAPABILITIES: Web Standard APIs only (fetch, Request, Response, URL, TextEncoder, crypto.subtle).
LIMITATIONS: no fs, no native modules, no long-running processes, limited to ~50ms CPU time.
LATENCY: runs at CDN edge, closest to user.

// app/api/geo/route.ts
export const runtime = "edge";

export async function GET(request: Request) {
  const country = request.headers.get("x-vercel-ip-country") || "unknown";
  return Response.json({ country });
}

GE_RECOMMENDATION

DEFAULT: use Node.js runtime for all routes.
USE_EDGE: only for middleware, simple redirects, geo-based logic.
REASON: GE projects use PostgreSQL (TCP connection), which requires Node.js runtime. Edge runtime cannot connect to database directly.


PERFORMANCE_IMPLICATIONS

Technique LCP Impact INP Impact CLS Impact
Streaming SSR Faster (content arrives sooner) Neutral Risk if skeletons wrong size
Selective Hydration Neutral Faster (interactive sooner) Neutral
Edge Runtime Faster (lower TTFB) Neutral Neutral
Server Components Faster (less JS) Faster (less main thread work) Neutral
Suspense Boundaries Faster (progressive loading) Faster (less blocking) Risk if no skeleton

DEBUGGING_HYDRATION_ERRORS

STEP_1: read the full error message in browser console. React 19 includes detailed mismatch info.
STEP_2: check if error appears in development only (browser extension) or also in production.
STEP_3: search for new Date(), Math.random(), window., document., localStorage in the component.
STEP_4: check for invalid HTML nesting (<div> in <p>, <a> in <a>).
STEP_5: check for conditional rendering based on client state (theme, screen size, auth).
STEP_6: if third-party library, wrap in dynamic(() => import(...), { ssr: false }).

TOOL: React DevTools highlights hydration mismatches in development.
TOOL: NEXT_DEBUG_SSR=1 next build for detailed SSR debugging output.


SELF_CHECK

BEFORE_EVERY_PAGE:
- [ ] are all Suspense fallbacks sized to match real content?
- [ ] is there no direct use of browser APIs during server render?
- [ ] is there no Date/random in server-rendered output?
- [ ] is HTML nesting valid (no div in p, no a in a)?
- [ ] does the page work with JavaScript disabled?
- [ ] are heavy client components loaded with next/dynamic where appropriate?
- [ ] is suppressHydrationWarning used only on html tag and date-type content?
- [ ] is runtime selection appropriate (Node for DB, Edge only for simple logic)?