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 Pages | Blazor WASM | |
|---|---|---|
| Initial HTML | Complete (SEO-friendly) | Empty (SEO-hostile) |
| Time to first content | Fast | Slow (bundle download + compile) |
| Interactivity | Requires full page reloads | Instant after load |
| Server load | High (every page is rendered on server) | Low (server just serves static files) |
| Works without JS | Yes | No |
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 mode | How to configure |
|---|---|
| SSG (build time) | generateStaticParams() + no revalidate export |
| ISR | export 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
| Concept | Razor Pages | Blazor WASM | Next.js SSR | Next.js RSC |
|---|---|---|---|---|
| Where rendering happens | Server | Client | Server + Client | Server (components) + Client (interactive parts) |
| Initial HTML | Complete | Empty shell | Complete | Complete |
| JavaScript required for content | No | Yes | No | No |
| Interactive without JS | Yes (forms need round trips) | No | No | No |
| Component code in browser bundle | N/A | Yes (WASM) | Yes | Only Client Components |
| Direct DB access in component | N/A | No (needs API) | No (use API or server action) | Yes |
| Secrets in component | Yes | No | No (unless using server actions) | Yes |
| Hydration | N/A | N/A | Required | Required |
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:
- Trigger a redeploy (rebuilds all static pages)
- Use ISR (revalidation window determines how stale the page can be)
- 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/productsusingfetch()with{ next: { revalidate: 3600 } }(ISR: revalidate hourly) - Renders a grid of product cards
- Uses
generateStaticParamsequivalent behavior — check that the page is statically generated by runningnpm run buildand 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
useEffectto load a draft review fromlocalStorageafter 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 mode | Configured by | Data freshness | SEO | Best for |
|---|---|---|---|---|
| SSG | generateStaticParams() | Build time | Excellent | Blogs, docs, marketing |
| ISR | export const revalidate = N | Up to N seconds stale | Excellent | Catalogs, semi-static content |
| On-demand ISR | revalidatePath() / revalidateTag() | Immediately on webhook | Excellent | CMS-driven content |
| SSR | export const dynamic = "force-dynamic" | Always fresh | Excellent | Authenticated pages, real-time |
| Streaming SSR | <Suspense> boundaries | Always fresh | Excellent | Pages with mixed-speed data |
| CSR | "use client" + client fetching | TanStack Query managed | Poor (unless pre-rendered shell) | Dashboards, admin, real-time |
Key terms
| Term | Definition | .NET analog |
|---|---|---|
| SSR | Server renders complete HTML per request | Razor Pages |
| CSR | Browser renders everything from JS | Blazor WASM |
| SSG | HTML generated at build time, served as static file | Pre-compiled Razor views cached indefinitely |
| ISR | SSG pages regenerated periodically | [OutputCache] with sliding expiration |
| Hydration | Attaching React event handlers to server-rendered HTML | Blazor server pre-rendering + reconnect |
| RSC | Component runs only on server, code not shipped to browser | Code-behind that never reaches the client |
| Streaming | Progressive HTML delivery via HTTP chunked transfer | Response.Flush() mid-render |
| Hydration mismatch | Server and client render different HTML | Inconsistent ViewState |
"use client" | Opts a component into client-side execution | @rendermode InteractiveWebAssembly in Blazor |
Suspense | Boundary that shows a fallback while async children load | Loading placeholder + conditional rendering |
revalidatePath | Purge specific pages from the static cache | Response.RemoveOutputCacheItem() |
Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
ReferenceError: window is not defined | Browser global accessed during SSR | Use typeof window !== "undefined" guard or useEffect |
ReferenceError: localStorage is not defined | localStorage accessed during SSR | Move to useEffect or use cookies |
| Hydration warning in console | Server and client render different HTML | Check for Date.now(), browser globals, or user-specific data in render |
Event handlers cannot be passed to Client Component props | Adding onClick to a Server Component | Extract to a "use client" component |
| Props are not serializable | Passing functions or class instances from Server to Client | Pass only plain data; handle functions inside Client Component |
| SSG page not updating after data change | No revalidation configured | Add revalidate export or use revalidatePath() webhook |
useEffect not running | Component is a Server Component (no "use client") | Add "use client" directive at top of file |
Further Reading
- Next.js rendering documentation — covers Server Components, Client Components, and rendering strategies with diagrams
- Next.js data fetching documentation —
fetchoptions for caching, revalidation, and server actions - React Server Components RFC — the original design document explaining the motivation and architecture
- Understanding React’s hydration — the
hydrateRootAPI and what it does - Nuxt rendering modes — Vue equivalent of this article’s content, covering Universal SSR, SSG, and hybrid rendering in Nuxt
- Article 3.10 — Client-Side Routing — explains the
"use client"/ Server Component distinction in the context of routing and navigation