Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Server-Side Rendering and Hydration

For .NET engineers who know: Razor Pages (full server rendering), Blazor WASM (full client rendering), and the fundamentals of how browsers parse HTML You’ll learn: How Next.js and Nuxt blend server and client rendering — and what “hydration” means, why it can go wrong, and how React Server Components change the mental model entirely Time: 15-20 minutes

The .NET Way (What You Already Know)

You already know two rendering models from .NET. Razor Pages is pure server rendering: every request hits the server, the server runs C# code, and the server returns complete HTML. The browser paints it immediately. There is no JavaScript required for the page to be readable. Blazor WASM is pure client rendering: the server sends a near-empty HTML shell and a WebAssembly bundle, the browser downloads and compiles the WASM, and then JavaScript/WASM builds the entire UI in the browser. The page is blank until that process completes.

Razor Pages:
Browser → HTTP request → Server runs C# → Server returns complete HTML → Browser paints ✓

Blazor WASM:
Browser → HTTP request → Server returns <div id="app"></div> + WASM bundle
          → Browser downloads bundle → WASM runs → DOM built in browser ✓

Each has a clear tradeoff:

Razor PagesBlazor WASM
Initial HTMLComplete (SEO-friendly)Empty (SEO-hostile)
Time to first contentFastSlow (bundle download + compile)
InteractivityRequires full page reloadsInstant after load
Server loadHigh (every page is rendered on server)Low (server just serves static files)
Works without JSYesNo

The hybrid model in Next.js and Nuxt is the middle path: server renders complete HTML (like Razor Pages) and then JavaScript in the browser attaches event handlers to that HTML (like Blazor). This process of attaching JavaScript to server-rendered HTML is called hydration.

The Modern JS Way

What SSR Is and Why It Matters

SSR (Server-Side Rendering) in a JavaScript framework means the same JavaScript/TypeScript component code that normally runs in the browser is also executed on the server during the request cycle. The server produces complete HTML, sends it to the browser, the browser displays it immediately, and then downloads the JavaScript bundle to make it interactive.

Next.js SSR request:
Browser → GET /orders/42
Server → runs React components in Node.js → produces complete HTML string
       → sends HTML + <script> tags for JS bundle
Browser → paints HTML immediately (user sees content)
         → downloads JS bundle
         → "hydrates" — attaches React event handlers to the existing DOM
         → page becomes interactive

The benefits over pure client-side rendering (CSR):

  • SEO: search crawlers receive complete HTML without executing JavaScript. Google, Bing, and others can index the full page content.
  • First Contentful Paint (FCP): the browser paints real content immediately, before any JavaScript runs. Users on slow connections see the page rather than a blank screen or a spinner.
  • Core Web Vitals: Largest Contentful Paint (LCP) improves because the main content arrives in the initial HTML, not after a JavaScript fetch.

What Hydration Is

Hydration is the process of taking static server-rendered HTML and making it interactive by attaching React (or Vue) event handlers and state to the existing DOM nodes.

A useful mental model: the server renders a detailed architectural blueprint (HTML). The client receives that blueprint, looks at it, and then installs the plumbing and electricity (event handlers, state). The house exists — it just isn’t functional yet.

// What the server renders (simplified)
const serverHtml = `
<div data-reactroot="">
  <button>
    Clicked 0 times   <!-- server has no concept of click state -->
  </button>
</div>
`;

// What the client-side React code is
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Clicked {count} times
    </button>
  );
}

// During hydration:
// 1. React receives the server HTML
// 2. React renders Counter() in JavaScript — gets a virtual DOM representing <button>Clicked 0 times</button>
// 3. React compares this to the real DOM (the server HTML)
// 4. If they match: React attaches onClick to the existing <button> without touching the DOM
// 5. If they don't match: React logs a warning and re-renders (replacing server HTML with client HTML)

Step 4 is what makes hydration efficient: React reuses the server-rendered DOM instead of rebuilding it from scratch. This is why the user sees content immediately — there is no blank page while JavaScript boots.

Hydration Mismatches — the Most Common SSR Bug

A hydration mismatch occurs when the HTML the server renders differs from what the client-side React component would render. React detects the difference and logs a warning, then corrects the DOM. In development, mismatches are prominent errors. In production, they produce a flash of incorrect content.

Common causes:

1. Using Date.now() or Math.random() in render

// WRONG — server renders one timestamp, client renders another timestamp
function ArticleDate() {
  return <time>{new Date().toLocaleDateString()}</time>;
  // Server: "2/18/2026"
  // Client (running slightly later): "2/18/2026" — might match, might not
  // If the locale differs (server is UTC, browser is local): mismatch guaranteed
}

// CORRECT — pass the date as a prop from the server
async function ArticlePage({ params }: { params: { id: string } }) {
  const article = await articleService.getById(Number(params.id));
  return <ArticleDate publishedAt={article.publishedAt} />;
}

function ArticleDate({ publishedAt }: { publishedAt: string }) {
  // publishedAt is an ISO string — deterministic on both server and client
  return <time dateTime={publishedAt}>{new Date(publishedAt).toLocaleDateString("en-US")}</time>;
}

2. Reading browser globals on both server and client

// WRONG — window does not exist on the server
function ThemeToggle() {
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
  // Server: ReferenceError: window is not defined — crashes
}

// CORRECT — gate browser access with typeof window check
function ThemeToggle() {
  const prefersDark =
    typeof window !== "undefined"
      ? window.matchMedia("(prefers-color-scheme: dark)").matches
      : false; // server-side default
  // Server renders false (light mode). Client reads actual preference.
  // If the user prefers dark, there will be a mismatch — handled below with suppressHydrationWarning
}

3. Rendering user-specific data on the server from a shared request context

// WRONG — different users see each other's data if server caches per-render state
function UserGreeting() {
  const user = globalUserStore.current; // shared mutable server state — dangerous
  return <p>Hello, {user.name}</p>;
}

4. The suppressHydrationWarning escape hatch

For cases where a mismatch is expected and harmless (user’s local time, user’s theme preference), React provides suppressHydrationWarning:

// Suppress the mismatch warning for elements whose server/client values are intentionally different
function LocalTime({ utcTime }: { utcTime: string }) {
  return (
    <time suppressHydrationWarning dateTime={utcTime}>
      {new Date(utcTime).toLocaleTimeString()}
      {/* Server: UTC time. Client: local timezone. Different — but that's fine here. */}
    </time>
  );
}

Use this sparingly. It suppresses the warning but does not prevent the DOM replacement — the user still sees a flash of the server value being replaced by the client value.

useEffect Only Runs on the Client

In React, useEffect does not run during server-side rendering. It runs after the component mounts in the browser. This is the correct place for code that depends on browser APIs (window, document, localStorage, navigator):

"use client";
import { useState, useEffect } from "react";

function GeolocationBanner() {
  const [location, setLocation] = useState<string | null>(null);

  // This code ONLY runs in the browser, after hydration
  useEffect(() => {
    navigator.geolocation.getCurrentPosition((pos) => {
      setLocation(`${pos.coords.latitude}, ${pos.coords.longitude}`);
    });
  }, []);

  // Server renders null (no location). Client renders after geolocation resolves.
  // No mismatch — the initial render (null) matches on both sides.
  if (!location) return null;
  return <p>Your location: {location}</p>;
}

The pattern for browser-only state:

"use client";
import { useState, useEffect } from "react";

// Component that is only meaningful in the browser
function WindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const update = () =>
      setSize({ width: window.innerWidth, height: window.innerHeight });
    update(); // set initial value
    window.addEventListener("resize", update);
    return () => window.removeEventListener("resize", update);
  }, []);

  return <p>{size.width} x {size.height}</p>;
}

The initial render returns { width: 0, height: 0 } on both server and client — they match. After hydration, useEffect fires and updates to real values.

React Server Components (RSC)

React Server Components are the architectural shift introduced in Next.js 13’s App Router. The distinction from classic SSR is subtle but important:

  • Classic SSR: components run on the server to generate HTML, and then the same components run again on the client during hydration. The component code ships to the browser.
  • React Server Components: the component code runs only on the server. The HTML it produces is sent to the browser, but the component’s JavaScript is never shipped to the client bundle.
// app/orders/page.tsx — a Server Component (no "use client" directive)
// This component's code never appears in the browser's JavaScript bundle

import { db } from "@/lib/db"; // A database client — never sent to the browser

export default async function OrdersPage() {
  // Direct database access — no API layer needed for this component
  // You can also use secrets here (API keys, connection strings) — never exposed to browser
  const orders = await db.orders.findMany({
    where: { userId: await getCurrentUserId() },
    orderBy: { createdAt: "desc" },
  });

  return (
    <main>
      <h1>Your Orders</h1>
      {/* Passes data to a Client Component as props */}
      <OrderTable orders={orders} />
    </main>
  );
}
// components/OrderTable.tsx — a Client Component (needs interactivity)
"use client";
import { useState } from "react";

interface Props {
  orders: Order[]; // receives serializable data from the Server Component
}

export function OrderTable({ orders }: Props) {
  const [selected, setSelected] = useState<number | null>(null);

  return (
    <table>
      {orders.map((order) => (
        <tr
          key={order.id}
          onClick={() => setSelected(order.id)}
          className={selected === order.id ? "selected" : ""}
        >
          <td>{order.id}</td>
          <td>{order.customerName}</td>
        </tr>
      ))}
    </table>
  );
}

The result: the database query runs on the server, the results are serialized and sent to the browser as props, and only the interactive OrderTable component’s JavaScript is included in the bundle. The OrdersPage code — which may include ORM imports and business logic — never reaches the browser.

Traditional React (CSR or classic SSR):
Browser bundle includes: OrdersPage + db client + ORM + OrderTable + React

React Server Components:
Server-only: OrdersPage + db client + ORM (not shipped)
Browser bundle includes: OrderTable + React only

This is a significant bundle size reduction for data-heavy applications.

The Server/Client boundary rules:

// Server Components CAN:
// - use async/await at the top level
// - import server-only modules (database clients, file system, environment variables)
// - access secrets
// - pass serializable props to Client Components

// Server Components CANNOT:
// - use useState, useReducer, useEffect (no client state)
// - use event handlers (onClick, onChange)
// - use browser APIs (window, document)
// - use Context (React context is client-only)

// Client Components CAN:
// - use all React hooks
// - use browser APIs
// - render Server Components as children (passed as props)

// Client Components CANNOT:
// - import server-only modules (next/server will block this)
// - use async/await at the top level in their render function

Streaming SSR

Traditional SSR sends the complete HTML page only after every component has finished rendering. If one component makes a slow database query, the entire page is delayed. Streaming SSR solves this by sending HTML progressively — fast parts arrive immediately while slow parts stream in as they complete.

// app/orders/[id]/page.tsx — streaming with Suspense
import { Suspense } from "react";

export default function OrderDetailPage({ params }: { params: { id: string } }) {
  return (
    <main>
      {/* This renders immediately — no data required */}
      <h1>Order Details</h1>
      <Breadcrumbs />

      {/* This streams in when the slow query completes */}
      <Suspense fallback={<OrderDetailSkeleton />}>
        <OrderDetail id={Number(params.id)} />
        {/* OrderDetail is async — it fetches data before rendering */}
      </Suspense>

      {/* This streams independently — doesn't wait for OrderDetail */}
      <Suspense fallback={<RelatedOrdersSkeleton />}>
        <RelatedOrders orderId={Number(params.id)} />
      </Suspense>
    </main>
  );
}
// components/OrderDetail.tsx — async Server Component
async function OrderDetail({ id }: { id: number }) {
  const order = await orderService.getById(id); // slow query

  if (!order) notFound();

  return (
    <div>
      <p>Customer: {order.customerName}</p>
      <p>Total: {order.total}</p>
    </div>
  );
}

The browser receives the initial HTML with the heading and the skeleton loaders. As each Suspense boundary resolves (the async component finishes), Next.js streams the resolved HTML as a script tag that replaces the fallback. The user sees progressive content rather than a blank page.

This is closer in concept to ASP.NET’s Response.Flush() or HTTP chunked transfer encoding than to anything else in .NET.

Static Site Generation (SSG) and Incremental Static Regeneration (ISR)

Next.js pages do not have to be server-rendered on every request. Static pages are rendered once at build time and served as files — like deploying a pre-generated Razor view.

// app/blog/[slug]/page.tsx
// generateStaticParams tells Next.js which pages to pre-render at build time
export async function generateStaticParams() {
  const posts = await blogService.getAllPublished();
  return posts.map((post) => ({ slug: post.slug }));
}

// This page is statically generated — HTML file created at build time
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await blogService.getBySlug(params.slug);
  return <BlogPost post={post} />;
}

ISR (Incremental Static Regeneration) adds an expiry to static pages:

// app/products/[id]/page.tsx
export const revalidate = 3600; // re-generate this page at most once per hour

// Or, per-request revalidation
export async function generateStaticParams() { ... }

export default async function ProductPage({ params }: { params: { id: string } }) {
  // On first request after the 1-hour window: page is re-generated in the background
  // Subsequent requests during re-generation: get the previous version
  // After re-generation completes: get the new version
  const product = await productService.getById(Number(params.id));
  return <ProductDetail product={product} />;
}

ISR is like a cached [OutputCache] in ASP.NET with a server-side cache invalidation policy — but stateless and edge-deployable.

The Rendering Decision Tree

Is this page public and content-focused (blog, marketing, docs)?
├── Yes → SSG (static generation at build time)
│   └── Does the content change frequently?
│       ├── Yes → ISR (revalidate every N seconds)
│       └── No → Full SSG (revalidate only on redeploy)
│
└── No → Is the content user-specific or requires auth?
    ├── Yes → SSR (render on each request, can access cookies/session)
    │   └── Does it have slow data sections?
    │       └── Yes → SSR + Streaming with Suspense
    │
    └── No (real-time, highly interactive, low SEO need)
        └── CSR (Client-Side Rendering with TanStack Query)

Translated to Next.js / Nuxt config:

Rendering modeHow to configure
SSG (build time)generateStaticParams() + no revalidate export
ISRexport const revalidate = 60 (seconds)
SSR (per request)export const dynamic = "force-dynamic"
CSR (skip SSR)"use client" + no async data fetching in the component
Streaming SSR<Suspense> boundaries around async Server Components

Key Differences

ConceptRazor PagesBlazor WASMNext.js SSRNext.js RSC
Where rendering happensServerClientServer + ClientServer (components) + Client (interactive parts)
Initial HTMLCompleteEmpty shellCompleteComplete
JavaScript required for contentNoYesNoNo
Interactive without JSYes (forms need round trips)NoNoNo
Component code in browser bundleN/AYes (WASM)YesOnly Client Components
Direct DB access in componentN/ANo (needs API)No (use API or server action)Yes
Secrets in componentYesNoNo (unless using server actions)Yes
HydrationN/AN/ARequiredRequired

Gotchas for .NET Engineers

Gotcha 1: “window is not defined” — the most common SSR error

When your component code runs on the server, Node.js does not have browser globals. window, document, navigator, localStorage, sessionStorage, and location all throw ReferenceError. This crashes the server render.

// WRONG — crashes the server
const theme = localStorage.getItem("theme"); // at module level — runs on server

// WRONG — crashes the server (no useEffect guard)
"use client";
function ThemeConsumer() {
  const theme = localStorage.getItem("theme"); // in render — runs during SSR
  return <div className={theme ?? "light"}>{...}</div>;
}

// CORRECT — typeof guard for code outside components
const isBrowser = typeof window !== "undefined";
const theme = isBrowser ? localStorage.getItem("theme") : null;

// CORRECT — useEffect for component code (only runs in browser)
"use client";
import { useState, useEffect } from "react";

function ThemeConsumer() {
  const [theme, setTheme] = useState<string | null>(null); // server and initial client: null

  useEffect(() => {
    setTheme(localStorage.getItem("theme")); // only runs in browser, after hydration
  }, []);

  return <div className={theme ?? "light"}>{...}</div>;
}

If you have an entire component that only makes sense in the browser (maps, rich text editors, WebGL), use Next.js’s dynamic import with ssr: false:

import dynamic from "next/dynamic";

// MapComponent is never rendered on the server
const MapComponent = dynamic(() => import("@/components/MapComponent"), {
  ssr: false,
  loading: () => <div>Loading map...</div>,
});

Gotcha 2: Hydration mismatches from localStorage / session-dependent rendering

The most insidious hydration mismatch pattern: the server renders the logged-out state, the client reads the auth token from localStorage and renders the logged-in state. The mismatch error fires, and React replaces the server HTML with the client HTML — the user sees a flash.

// WRONG — server renders "Log in", client renders "Hello Chris" — mismatch
"use client";
function NavBar() {
  const user = JSON.parse(localStorage.getItem("user") ?? "null");
  return <nav>{user ? `Hello ${user.name}` : "Log in"}</nav>;
}

// CORRECT — defer user-specific rendering to after hydration
"use client";
import { useState, useEffect } from "react";

function NavBar() {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const stored = localStorage.getItem("user");
    if (stored) setUser(JSON.parse(stored));
  }, []);

  // Server and initial client both render: "Log in" — no mismatch
  // After hydration: useEffect fires and sets user — client re-renders correctly
  return <nav>{user ? `Hello ${user.name}` : "Log in"}</nav>;
}

// BETTER — store auth in cookies (readable on the server)
// Then server and client agree on the auth state from the start

Using cookies for auth state instead of localStorage eliminates this class of bug. The server can read request.cookies and render the correct state from the start.

Gotcha 3: Server Components cannot be made interactive

A common mistake when first using the App Router: adding an onClick or useState to a Server Component and being confused by the error message. The fix is always to extract the interactive part into a separate Client Component.

// WRONG — Server Component trying to be interactive
// app/orders/page.tsx (no "use client")
export default async function OrdersPage() {
  const orders = await db.orders.findMany();

  return (
    <main>
      {orders.map((order) => (
        // ERROR: Event handlers cannot be passed to Client Component props
        <div key={order.id} onClick={() => console.log(order.id)}>
          {order.customerName}
        </div>
      ))}
    </main>
  );
}

// CORRECT — extract the interactive part
// app/orders/page.tsx
export default async function OrdersPage() {
  const orders = await db.orders.findMany();
  return <OrderList orders={orders} />; // pass data as props
}

// components/OrderList.tsx
"use client"; // interactive behavior lives here
export function OrderList({ orders }: { orders: Order[] }) {
  return (
    <main>
      {orders.map((order) => (
        <div key={order.id} onClick={() => console.log(order.id)}>
          {order.customerName}
        </div>
      ))}
    </main>
  );
}

The split follows the same principle as ASP.NET Blazor’s Server/WASM split: put the data-fetching and business logic on the server, put the interactivity on the client.

Gotcha 4: Props passed from Server Components to Client Components must be serializable

Server Components pass data to Client Components via props. These props are serialized to JSON before being sent to the browser (they travel over the network as part of the RSC payload). Non-serializable values cannot be passed as props.

// WRONG — functions, class instances, and Promises are not serializable
export default async function OrdersPage() {
  const orders = await db.orders.findMany();

  return (
    <OrderList
      orders={orders}
      onDelete={(id) => deleteOrder(id)}  // ERROR: functions not serializable
      dateFormatter={new Intl.DateTimeFormat("en-US")} // ERROR: class instance
    />
  );
}

// CORRECT — pass only serializable data (primitives, plain objects, arrays)
export default async function OrdersPage() {
  const orders = await db.orders.findMany();

  return (
    <OrderList
      orders={orders}
      // The Client Component handles its own event handlers
      // The Client Component formats dates using its own Intl instances
    />
  );
}

Serializable types: string, number, boolean, null, undefined, plain objects ({}), arrays, and instances of Date (serialized as ISO strings). Not serializable: functions, class instances with methods, Map, Set, Symbol, BigInt.

Gotcha 5: SSG pages with dynamic data require explicit cache invalidation

SSG pages are generated at build time and served from a CDN. When the underlying data changes — a product price update, a blog post edit — the static page does not automatically update. You must either:

  1. Trigger a redeploy (rebuilds all static pages)
  2. Use ISR (revalidation window determines how stale the page can be)
  3. Use on-demand revalidation (call revalidatePath() from a webhook or server action)
// app/api/webhooks/cms/route.ts — on-demand ISR revalidation
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const body = await request.json();

  // Verify the webhook signature (never skip this in production)
  const signature = request.headers.get("x-webhook-signature");
  if (!verifySignature(signature, body)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // Revalidate the specific page that changed
  if (body.type === "post.published") {
    revalidatePath(`/blog/${body.slug}`);
    revalidatePath("/blog"); // also invalidate the index
  }

  return NextResponse.json({ revalidated: true });
}

This is the JS equivalent of Response.RemoveOutputCacheItem() in ASP.NET — purge a specific cached URL on demand rather than waiting for expiry.

Hands-On Exercise

Build a product catalog page that demonstrates each rendering mode.

Setup: npx create-next-app@latest --typescript product-catalog

Task 1 — SSG product list page. Create app/products/page.tsx as a Server Component that:

  • Fetches all products from https://fakestoreapi.com/products using fetch() with { next: { revalidate: 3600 } } (ISR: revalidate hourly)
  • Renders a grid of product cards
  • Uses generateStaticParams equivalent behavior — check that the page is statically generated by running npm run build and inspecting the output (pages marked with are static)

Task 2 — SSR product detail page. Create app/products/[id]/page.tsx that:

  • Fetches a specific product: https://fakestoreapi.com/products/{id}
  • Uses notFound() if the product ID is invalid
  • Uses streaming: wrap the “Related Products” section (fetched from /products/category/{category}) in a <Suspense> boundary with a skeleton fallback
  • Force the page to SSR with export const dynamic = "force-dynamic" and verify in DevTools that the page arrives as complete HTML before any JavaScript runs

Task 3 — Demonstrate hydration. Create a Client Component ProductReviewForm that:

  • Initially renders with no values (matches server)
  • Uses useEffect to load a draft review from localStorage after hydration
  • Observe in DevTools that the form renders twice: once with empty state (SSR), once with the draft (after hydration)

Task 4 — Trigger a mismatch intentionally. In the product detail page, add a component that renders Date.now() directly (without suppressHydrationWarning). Run the dev server and observe the hydration warning in the console. Then fix it using the patterns from the Gotchas section.

Task 5 — RSC boundary. Refactor the product list so that:

  • app/products/page.tsx (Server Component) fetches data and passes it to <ProductGrid products={products} />
  • components/ProductGrid.tsx (Client Component) handles filter state and sorting
  • Verify that no fetch code appears in the browser’s JavaScript bundle (Network tab → JS files — the fetch() call to the API should not appear)

Quick Reference

Rendering modeConfigured byData freshnessSEOBest for
SSGgenerateStaticParams()Build timeExcellentBlogs, docs, marketing
ISRexport const revalidate = NUp to N seconds staleExcellentCatalogs, semi-static content
On-demand ISRrevalidatePath() / revalidateTag()Immediately on webhookExcellentCMS-driven content
SSRexport const dynamic = "force-dynamic"Always freshExcellentAuthenticated pages, real-time
Streaming SSR<Suspense> boundariesAlways freshExcellentPages with mixed-speed data
CSR"use client" + client fetchingTanStack Query managedPoor (unless pre-rendered shell)Dashboards, admin, real-time

Key terms

TermDefinition.NET analog
SSRServer renders complete HTML per requestRazor Pages
CSRBrowser renders everything from JSBlazor WASM
SSGHTML generated at build time, served as static filePre-compiled Razor views cached indefinitely
ISRSSG pages regenerated periodically[OutputCache] with sliding expiration
HydrationAttaching React event handlers to server-rendered HTMLBlazor server pre-rendering + reconnect
RSCComponent runs only on server, code not shipped to browserCode-behind that never reaches the client
StreamingProgressive HTML delivery via HTTP chunked transferResponse.Flush() mid-render
Hydration mismatchServer and client render different HTMLInconsistent ViewState
"use client"Opts a component into client-side execution@rendermode InteractiveWebAssembly in Blazor
SuspenseBoundary that shows a fallback while async children loadLoading placeholder + conditional rendering
revalidatePathPurge specific pages from the static cacheResponse.RemoveOutputCacheItem()

Common errors and fixes

ErrorCauseFix
ReferenceError: window is not definedBrowser global accessed during SSRUse typeof window !== "undefined" guard or useEffect
ReferenceError: localStorage is not definedlocalStorage accessed during SSRMove to useEffect or use cookies
Hydration warning in consoleServer and client render different HTMLCheck for Date.now(), browser globals, or user-specific data in render
Event handlers cannot be passed to Client Component propsAdding onClick to a Server ComponentExtract to a "use client" component
Props are not serializablePassing functions or class instances from Server to ClientPass only plain data; handle functions inside Client Component
SSG page not updating after data changeNo revalidation configuredAdd revalidate export or use revalidatePath() webhook
useEffect not runningComponent is a Server Component (no "use client")Add "use client" directive at top of file

Further Reading