Deep Dive 03 - React Server Components
Deep Dive 03 - React Server Components
Hey everyone! Welcome back to Namaste React Deep Dives!
React Server Components are the biggest architectural shift in React's history. They change how we think about the server/client boundary. Every senior React developer needs to understand this deeply!
What we will cover:
- The rendering models history — CSR → SSR → SSG → ISR
- What are React Server Components (RSC)?
- "use client" and "use server" directives
- RSC vs SSR — they're completely different things!
- RSC with Next.js App Router
- Streaming with Suspense + RSC
- async/await directly in Server Components
- RSC data flow patterns
- When to use Server vs Client Components
- Interview Questions
1. Rendering Models — The History
┌─────────────────────────────────────────────────────────────┐ │ RENDERING MODELS EVOLUTION │ └─────────────────────────────────────────────────────────────┘ ERA 1: MPA (Multi-Page Application) — 1990s-2010s ==================================================== Browser requests URL → Server generates FULL HTML → Sends to browser Every click = full page reload Examples: PHP, Rails, Django ✅ Simple, SEO-friendly, fast first load ❌ Full page reloads, bad interactivity ERA 2: CSR (Client-Side Rendering) — 2010s-2020s ==================================================== Browser gets empty HTML + JS bundle → JS runs → builds the UI Examples: React SPA (Create React App), Vue, Angular ✅ Smooth UI, no page reloads, rich interactivity ❌ Slow initial load, bad SEO (empty HTML), large JS bundles User experience: 1. Request page 2. Download empty HTML (fast) 3. Download JavaScript bundle (slow!) 4. React renders the page (slow!) 5. User FINALLY sees content. Total: 3-5 seconds on slow networks! ERA 3: SSR (Server-Side Rendering) — 2019+ ============================================ Request → Server runs React → Full HTML sent → Browser shows it Then: JS bundle loads → React "hydrates" → Interactive Examples: Next.js Pages Router, Remix ✅ Fast first paint, SEO-friendly ❌ Still ships full JS to client, hydration cost, slow TTFB for heavy pages ERA 4: SSG (Static Site Generation) — 2019+ ============================================= At BUILD TIME: Server runs React → Full HTML files generated Files served from CDN — no server needed! Examples: Gatsby, Next.js static export ✅ Blazing fast, perfect SEO, cheap hosting ❌ Only for static content. Can't show real-time/user-specific data. ERA 5: ISR (Incremental Static Regeneration) — 2020+ ====================================================== Like SSG but pages can revalidate after N seconds. Serve cached page, regenerate in background. Next.js specialty. ✅ Fast like SSG + can update over time ❌ Complex caching logic, not truly real-time ERA 6: RSC (React Server Components) — 2023+ ============================================== Components render ON THE SERVER with ZERO JS sent to client. Only the CLIENT components ship JS. The server can render async components directly! Examples: Next.js App Router (default) ✅ Radical bundle reduction, data fetching on server, full HTML ❌ New mental model, limited ecosystem, no state/effects in server components
2. What Are React Server Components?
// React Server Components = Components that run ONLY on the server.
// They NEVER ship their code to the browser!
// TRADITIONAL COMPONENT (Client Component):
function ProductCard({ product }) {
// This JavaScript runs in the browser
// AND is shipped in the JS bundle
const [isLiked, setIsLiked] = useState(false);
return (
<div>
<h2>{product.name}</h2>
<button onClick={() => setIsLiked(!isLiked)}>
{isLiked ? "♥" : "♡"}
</button>
</div>
);
}
// SERVER COMPONENT (NEW!):
async function ProductCard({ productId }) {
// This JavaScript runs ONLY on the server
// ZERO JavaScript shipped to browser!
const product = await db.products.findById(productId); // ← Direct DB access!
const reviews = await db.reviews.findByProduct(productId);
return (
<div>
<h2>{product.name}</h2>
<p>{reviews.length} reviews</p>
</div>
);
// No useState, no useEffect, no event handlers = no JS bundle!
}
// THE KEY INSIGHT:
// ┌─────────────────────────────────────────────────────────┐
// │ Server Component │
// │ - Runs on server │
// │ - Can access DB, filesystem, secrets directly │
// │ - async/await directly │
// │ - ZERO JS sent to browser │
// │ - NO useState, NO useEffect, NO event handlers │
// └─────────────────────────────────────────────────────────┘
// ┌─────────────────────────────────────────────────────────┐
// │ Client Component │
// │ - Runs in browser (and server for initial HTML) │
// │ - Interactive: useState, useEffect, event handlers │
// │ - JS IS shipped to browser │
// │ - Marked with "use client" at the top │
// └─────────────────────────────────────────────────────────┘
3. "use client" and "use server" Directives
// By default in Next.js App Router: ALL components are Server Components!
// Add "use client" only when you need browser APIs, state, or effects.
// WITHOUT DIRECTIVE — Server Component (default in App Router):
// app/ProductList.jsx
async function ProductList() {
const products = await fetch("https://api.example.com/products").then(r => r.json());
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name} — ₹{p.price}</li>
))}
</ul>
);
}
export default ProductList;
// WITH "use client" DIRECTIVE — Client Component:
// app/AddToCartButton.jsx
"use client"; // ← directive at the TOP of the file, before imports!
import { useState } from "react";
export function AddToCartButton({ product }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? "Added!" : "Add to Cart"}
</button>
);
}
// MIXING SERVER AND CLIENT COMPONENTS:
// app/ProductCard.jsx (Server Component)
import { AddToCartButton } from "./AddToCartButton"; // ← Client Component
async function ProductCard({ id }) {
const product = await db.products.findById(id); // ← server-only!
return (
<div>
<h2>{product.name}</h2>
<p>₹{product.price}</p>
<AddToCartButton product={product} /> {/* ← Client Component inside Server! */}
</div>
);
}
// Server component renders its JSX on the server.
// Where it finds <AddToCartButton />, it creates a "hole" in the RSC Payload.
// That hole is filled by the Client Component on the client!
// "use server" — Server Actions (functions that run on server from client):
// app/actions.js
"use server";
export async function addToCart(productId, userId) {
// Runs on server! Can access DB directly.
await db.cart.add({ userId, productId });
revalidatePath("/cart"); // ← Next.js: refresh the cart page
}
// Usage in Client Component:
"use client";
import { addToCart } from "./actions";
export function AddToCartButton({ product, userId }) {
return (
<button onClick={() => addToCart(product.id, userId)}>
Add to Cart
</button>
);
}
// onClick → calls server function → updates DB → revalidates page
// No API routes needed! Server Actions replace REST endpoints!
4. RSC vs SSR — Completely Different!
COMMON CONFUSION: RSC and SSR are NOT the same thing!
Let's compare them clearly:
┌───────────────────────────────────────────────────────────────────┐
│ SSR (Server-Side Rendering) │
│ │
│ Server runs ALL React components → Full HTML string │
│ Browser receives HTML → Shows content (fast first paint!) │
│ React JS bundle downloads → React "hydrates" the HTML │
│ (attaches event listeners, makes interactive) │
│ │
│ ALL component code still ships to browser for hydration! │
│ Client re-runs the component tree to "take over" from the server. │
│ Bundle size: SAME as pure CSR │
│ │
│ Purpose: Fast initial render, SEO │
└───────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ RSC (React Server Components) │
│ │
│ Server Components render on server → RSC Payload (special format)│
│ RSC Payload is NOT HTML — it's a description of the UI tree │
│ Client downloads ONLY the Client Component JavaScript │
│ Server Component code NEVER reaches the browser │
│ │
│ Bundle size: MUCH smaller (server component code stays on server)│
│ No hydration needed for server components │
│ │
│ Purpose: Eliminate unnecessary JavaScript │
└───────────────────────────────────────────────────────────────────┘
CAN THEY WORK TOGETHER? YES!
In Next.js App Router with RSC:
1. Request arrives
2. Server Components render to RSC Payload
3. Next.js wraps in initial HTML (SSR)
4. Browser shows HTML immediately (fast paint, SSR benefit)
5. Client Components hydrate with their JS (interactive)
6. Server Component code never sent to browser (RSC benefit)
It's SSR + RSC together!
HOW RSC PAYLOAD WORKS:
========================
RSC Payload is a streaming JSON-like format, not HTML.
Server sends something like:
{
"type": "div",
"props": { "className": "product" },
"children": [
{ "type": "h2", "props": {}, "children": "Pizza Palace" },
{ "type": "$Laddtocart", "props": { "productId": "123" } }
// $L prefix = "this is a Client Component" (load its JS!)
]
}
The client has the AddToCartButton JS → renders it interactively!
The ProductCard code never reached the client!
5. RSC with Next.js App Router
// Next.js App Router (13+) uses RSC by default!
// All files in app/ are Server Components unless marked "use client".
// FOLDER STRUCTURE:
app/
├── layout.jsx ← Server Component (persistent layout)
├── page.jsx ← Server Component (route's main content)
├── loading.jsx ← Server Component (automatic Suspense fallback)
├── error.jsx ← Error boundary ("use client" required!)
├── not-found.jsx ← Server Component (404 page)
│
└── restaurant/
└── [id]/
├── page.jsx ← Server Component (restaurant detail)
└── ReviewForm.jsx ← Client Component ("use client")
// app/page.jsx (Home page — Server Component):
import { RestaurantList } from "./RestaurantList";
export default async function Home() {
return (
<main>
<h1>FoodRocket</h1>
<RestaurantList />
</main>
);
}
// app/RestaurantList.jsx (Server Component):
async function RestaurantList() {
// Direct DB query — no API needed!
const restaurants = await prisma.restaurant.findMany({
orderBy: { rating: "desc" },
take: 20,
});
return (
<div className="grid">
{restaurants.map(r => (
<RestaurantCard key={r.id} restaurant={r} />
))}
</div>
);
}
// app/restaurant/[id]/page.jsx (Server Component):
export default async function RestaurantPage({ params }) {
// params.id from URL /restaurant/123
const restaurant = await prisma.restaurant.findById(params.id);
if (!restaurant) notFound(); // ← Next.js utility
return (
<div>
<h1>{restaurant.name}</h1>
<p>Rating: {restaurant.avgRating}★</p>
<ReviewForm restaurantId={restaurant.id} /> {/* Client Component */}
</div>
);
}
// app/restaurant/[id]/ReviewForm.jsx (Client Component — needs interactivity):
"use client";
import { useState } from "react";
import { submitReview } from "./actions";
export function ReviewForm({ restaurantId }) {
const [text, setText] = useState("");
const [rating, setRating] = useState(5);
const handleSubmit = async (e) => {
e.preventDefault();
await submitReview({ restaurantId, text, rating });
setText("");
};
return (
<form onSubmit={handleSubmit}>
<textarea value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Submit Review</button>
</form>
);
}
// METADATA API (Server Component feature):
export const metadata = {
title: "FoodRocket — Order Food Online",
description: "Order from 500+ restaurants in your city",
openGraph: {
title: "FoodRocket",
images: ["/og-image.png"],
},
};
// No helmet or react-helmet needed! Just export metadata from page.jsx!
6. Streaming with Suspense + RSC
// RSC + Suspense enables STREAMING — send HTML progressively!
// Don't wait for ALL data before sending ANYTHING!
// TRADITIONAL SSR:
// 1. Wait for ALL API calls to complete.
// 2. Render full HTML.
// 3. Send everything.
// Time to First Byte (TTFB) = slowest data fetch!
// STREAMING SSR + RSC:
// 1. Send shell HTML immediately (header, navigation, layout).
// 2. Stream restaurant list as soon as that data is ready.
// 3. Stream reviews as soon as review data is ready.
// TTFB = just the shell!
// app/restaurant/[id]/page.jsx (Streaming):
import { Suspense } from "react";
export default async function RestaurantPage({ params }) {
return (
<div>
<RestaurantHeader id={params.id} /> {/* loads fast */}
<Suspense fallback={<MenuSkeleton />}> {/* shows skeleton while loading */}
<MenuSection id={params.id} /> {/* streams when ready */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsSection id={params.id} /> {/* streams independently! */}
</Suspense>
</div>
);
}
// async component inside Suspense boundary:
async function MenuSection({ id }) {
const menu = await getMenu(id); // ← This fetch can take time
return (
<ul>
{menu.items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
async function ReviewsSection({ id }) {
const reviews = await getReviews(id); // ← Different fetch, different timing
return (
<div>
{reviews.map(r => <Review key={r.id} review={r} />)}
</div>
);
}
// WHAT THE USER EXPERIENCES:
// 0ms: Shell HTML arrives. Header visible!
// 200ms: MenuSection data arrives. Menu streams in, replaces skeleton.
// 500ms: ReviewsSection data arrives. Reviews stream in.
// User sees CONTENT progressively, not a spinner for 500ms!
// This is MUCH better than:
// 0ms: Spinner shows.
// 500ms: Everything appears at once.
7. Data Fetching Patterns with RSC
// PATTERN 1: Fetch at the top, pass as props (sequential):
async function Page({ id }) {
const restaurant = await getRestaurant(id);
const menu = await getMenu(id); // ← waits for restaurant first!
const reviews = await getReviews(id); // ← waits for menu!
// Total time: sum of all fetches!
return <div>...</div>;
}
// PATTERN 2: Parallel fetch (faster):
async function Page({ id }) {
// All fetches start SIMULTANEOUSLY:
const [restaurant, menu, reviews] = await Promise.all([
getRestaurant(id),
getMenu(id),
getReviews(id),
]);
// Total time: slowest single fetch!
return <div>...</div>;
}
// PATTERN 3: Co-located fetch in each component (best for streaming):
// Each component fetches its own data.
// Suspense boundaries allow streaming each piece independently.
async function MenuSection({ id }) {
const menu = await getMenu(id); // ← fetches independently
return <MenuList items={menu.items} />;
}
async function ReviewsSection({ id }) {
const reviews = await getReviews(id); // ← fetches independently
return <ReviewsList reviews={reviews} />;
}
// These run in PARALLEL! React starts fetching both when the component tree renders.
// No artificial waterfall!
// NEXT.JS DATA CACHING:
// Next.js extends fetch() with caching options:
const data = await fetch("https://api.example.com/products", {
cache: "force-cache", // ← cache forever (like SSG)
// or:
cache: "no-store", // ← never cache (always fresh)
// or:
next: { revalidate: 60 }, // ← cache for 60 seconds, then revalidate (ISR!)
});
// This is how Next.js does SSG, SSR, and ISR — through fetch() options!
8. When to Use Server vs Client Components
USE SERVER COMPONENT WHEN:
===========================
✅ Fetching data (API calls, DB queries)
✅ Accessing backend resources (DB, filesystem, internal APIs)
✅ Keeping sensitive information on server (API keys, tokens)
✅ Large dependencies that shouldn't ship to browser (parsers, SDKs)
✅ Static content (blog posts, product listings, navigation)
USE CLIENT COMPONENT WHEN:
===========================
✅ Interactivity (onClick, onChange, form submissions)
✅ React state: useState, useReducer
✅ React effects: useEffect, useLayoutEffect
✅ Browser-only APIs: localStorage, window, navigator, Geolocation
✅ Custom hooks that use the above
DECISION TREE:
==============
Does this component need...
├── onClick / onChange / form? → "use client"
├── useState / useEffect? → "use client"
├── localStorage / window? → "use client"
└── None of the above?
├── Needs data from DB? → Server Component ✅
├── Needs sensitive env vars? → Server Component ✅
└── Pure UI from props? → Server Component ✅ (default!)
COMMON PATTERNS:
=================
// Server Component as container, Client Component as leaf:
async function ProductPage({ id }) { // ← Server
const product = await getProduct(id);
return (
<div>
<ProductDetails product={product} /> {/* Server */}
<AddToCartButton productId={id} /> {/* Client */}
<ProductReviews id={id} /> {/* Server */}
</div>
);
}
// Keep the "use client" boundary as LOW in the tree as possible!
// This maximizes how much code stays on the server.
// CAN'T DO:
async function ServerComponent() {
const data = await getData();
return <ClientComponent onSuccess={async () => {
// ❌ Can't pass Server-side functions (async functions) as props to Client Components!
await db.update(data.id);
}} />;
}
// CAN DO with Server Actions:
// actions.js: "use server"
// ClientComponent receives the server action as a prop.
Interview Questions
Q: What is the difference between SSR and React Server Components?
"SSR (Server-Side Rendering) renders the full React tree on the server to HTML, but the entire JavaScript bundle still ships to the client for hydration — so client re-runs everything. RSC (React Server Components) runs certain components on the server with ZERO JavaScript sent to the browser for those components. Only Client Components ship JS. RSC reduces bundle size dramatically. They can be combined: SSR provides the initial HTML, RSC ensures server component code never reaches the client."
Q: What is the "use client" directive?
"'use client' is a file-level directive placed at the top of a file (before imports) to mark all components in that file as Client Components. In Next.js App Router, all components default to Server Components. Adding 'use client' opts that component (and anything it imports) into the client bundle. It creates a 'boundary' — below it in the import graph, components are client-side. Above it can be Server Components."
Q: How does streaming work with RSC?
"Streaming sends HTML progressively instead of waiting for all data. With RSC and Suspense boundaries, each wrapped component can stream independently. React sends the page shell (header, layout) immediately, then streams each Suspense boundary's content as its async data resolves. Users see content appear progressively rather than waiting for a spinner. This is enabled by HTTP streaming (chunked transfer encoding) combined with React's ability to serialize and stream RSC payloads."
Q: Can a Server Component import a Client Component?
"Yes! A Server Component can import and render a Client Component — this is the typical pattern. The Server Component renders its JSX on the server and where it finds a Client Component, React creates a placeholder in the RSC payload. The client downloads only the Client Component's JavaScript and renders it interactively. The reverse (Client Component importing a Server Component) doesn't work directly, but you can pass Server Components as children/props to Client Components."
Key Points to Remember
| Concept | Key Takeaway |
|---|---|
| Server Component | Runs on server only. ZERO JS to browser. Can use async/await, DB, secrets directly. |
| Client Component | "use client" directive. Can use useState, useEffect, browser APIs. JS ships to browser. |
| "use client" | File-level directive. Creates client boundary. Everything imported below it is client-side. |
| "use server" | Server Actions — functions callable from client that run on the server. Replaces API routes. |
| RSC vs SSR | SSR: all JS to client for hydration. RSC: server component JS NEVER sent to client. |
| RSC Payload | Streaming JSON-like format describing UI tree. Not HTML. Client fills "holes" with client components. |
| Streaming | Suspense boundaries stream independently. Users see content progressively. No full-page spinner. |
| App Router default | All components in Next.js app/ are Server Components by default. Opt into client with "use client". |
| Data fetching | Fetch directly in Server Components. Use Promise.all for parallel. Use next:{revalidate} for ISR. |
What's Next?
In Deep Dive 04, we master Performance Optimization — React Profiler, virtualization, memoization strategies, Web Vitals improvement, and the advanced patterns senior engineers use!
Keep coding, keep learning! See you in the final Deep Dive!
Post a Comment