1. Server/Client Components and Hydration
Server/Client Components and Hydration
On this page
Before the App Router (pure React, legacy Pages Router), every component executed on the client; the browser downloaded JavaScript bundles and rendered the UI. The App Router shifted the default to Server Components, which is easily mistaken for traditional SSR (the server pre-rendering raw HTML and serving it). In reality, the underlying mechanism is significantly more sophisticated and directly influences how component trees should be architected to optimize user interactivity.
1. Server Components are not traditional SSR
- Server Components render entirely on the server and send zero JavaScript to the client for those parts, allowing direct
awaitcalls inside component bodies. - Navigating routes via
<Link>in Next.js does not fetch plain HTML (unlike traditional SSR or standard<a>tag transitions that trigger full page reloads). Instead, it streams a specialized binary-like format called the RSC Payload (React Server Component Payload), a serialized data tree representing the UI. - The client still requires a lightweight React runtime to parse this RSC Payload and surgically "patch" the corresponding nodes in the existing DOM, preserving unchanged sections (header, sidebar) as well as the active state of existing Client Components within them. This mechanism provides SPA-like, seamless page transitions without white-screen flashes.
2. Hydration
- Client Components (
'use client') are still pre-rendered on the server into static HTML and delivered immediately for rapid initial paint (users see the UI instantly without waiting for JavaScript to finish downloading). - However, that initial HTML is merely a static shell without attached event listeners and is not yet interactive (clicks trigger no actions).
- Hydration: once the Client Component's JavaScript bundle downloads, React reconciles with the pre-rendered HTML (without re-creating DOM nodes from scratch), attaches event listeners (
onClick, etc.), and initializes state (useState). Only after hydration completes does the component become fully interactive.
Server:
1. React renders component tree to plain HTML string
2. Sends HTML to browser
3. Browser displays content immediately — fast first paint
Client:
4. Browser downloads JS bundle
5. React walks the component tree
6. Matches each component to existing DOM node
7. Attaches event listeners
8. App becomes interactive
-
Step 6 is critical — React expects server HTML to exactly match what it would render on the client. Mismatch = hydration error.
-
Hydration Mismatches — Common Causes
// ❌ Time differs between server and client render
<div>Current time: {new Date().toLocaleTimeString()}</div>
// ❌ Random values
<div>{Math.random()}</div>
// ❌ Browser-only APIs used during render
<div>{window.innerWidth}px wide</div>
// ❌ Data that changes between server and client
<div>{localStorage.getItem("theme")}</div>
- Fix 1 — useEffect for client-only values
function Greeting() {
const [time, setTime] = useState(null); // null on server — no mismatch
useEffect(() => {
setTime(new Date().toLocaleTimeString()); // runs only on client
}, []);
if (!time) return <div>Loading...</div>;
return <div>Current time: {time}</div>;
}
- Fix 2 —
suppressHydrationWarningfor intentional differences (use sparingly)
<div suppressHydrationWarning>{new Date().toLocaleTimeString()}</div>
- Fix 3 —
dynamicwithssr: falsefor components that use browser APIs entirely
const BrowserOnlyChart = dynamic(() => import("./Chart"), { ssr: false });
3. The issue with heavy Client Components
- If a page contains large, deeply nested Client Components, all associated JavaScript must download and hydrate before interactivity is unlocked, degrading UX on slower connections (users see the UI but experience input latency or unresponsive clicks).
0ms → HTML arrives, user sees content ✅ looks interactive
800ms → JS bundle downloads ⏳ looks interactive but isn't
1200ms → hydration completes ✅ actually interactive
-This gap is measured by TTI (Time To Interactive). A large JS bundle makes it worse. This is what React Server Components solve.
4. Best practice: push Client Components down to leaf nodes
- Core principle: place
'use client'strictly on the smallest component requiring state, event handlers, or React lifecycle hooks, never wrapping a top-level parent component around an entire subtree just because a tiny sub-element requires interactivity. - Example: a product page with static details (name, description, image) and an interactive "Add to Cart" button (requires state):
// ProductPage.tsx (Server Component by default)
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<img src={product.image} />
<AddToCartButton productId="{product.id}"/>
</div>
);
}
// AddToCartButton.tsx
'use client';
export function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false);
return <button onClick={() => { setLoading(true); addToCart(productId); }}>
Add to Cart
</button>;
}
- Outcome: title, description, and image components ship zero client-side JavaScript and bypass hydration entirely. Only the button requires downloading JS and running hydration. The primary benefit extends beyond smaller bundle sizes: the vast majority of the page becomes interactive and usable almost immediately without waiting for a heavy parent tree to hydrate.
