Server Components & Next.js
React Server Components, the use client boundary, streaming, and Next.js App Router with server actions.
React Server Components (RSC)#
Server Components render on the server and ship their result (a serialized RSC payload) to the client — zero client JS for that component. They can read the filesystem, hit a database, or await directly, but cannot use state, effects, refs, or browser APIs.
| Server Component | Client Component | |
|---|---|---|
| Runs | Server (build/request) | Server (SSR) + browser (hydrate) |
useState/useEffect |
❌ | ✅ |
Event handlers (onClick) |
❌ | ✅ |
async/await in body |
✅ | ❌ |
| Access DB/filesystem/secrets | ✅ | ❌ |
| Ships JS to client | No | Yes |
| Default in App Router | ✅ | Opt-in via "use client" |
// app/page.jsx — Server Component by default
import db from "@/lib/db";
export default async function Page() {
const posts = await db.post.findMany(); // runs on the server only
return (
<main>
{posts.map((p) => <Article key={p.id} post={p} />)}
<LikeButton /> {/* a Client Component for interactivity */}
</main>
);
}
The "use client" boundary 🎯#
"use client" at the top of a file marks it (and its import subtree) as a Client Component. It's a boundary, not a per-component tag: everything imported into a client module is also client. Server Components can render Client Components and pass serializable props; the reverse (importing a Server Component into a client one) isn't allowed, but you can pass a server component as children/props.
"use client";
import { useState } from "react";
export default function LikeButton() {
const [likes, setLikes] = useState(0);
return <button onClick={() => setLikes((l) => l + 1)}>👍 {likes}</button>;
}
⚠️ Props crossing the server→client boundary must be serializable — you can't pass functions, class instances, or Dates-as-methods. Keep client components at the leaves to minimize shipped JS. 🟢
flowchart TD
A["Server Component tree (no client JS)"] --> B["Fetches data, renders HTML/RSC payload"]
B --> C["Reaches 'use client' boundary"]
C --> D["Client Component (hydrated in browser)"]
D --> E["useState / onClick / effects work here"]
Streaming & Suspense#
The server can stream HTML in chunks. Wrap slow parts in <Suspense> with a fallback — the shell flushes immediately and slow subtrees stream in when ready, improving TTFB and perceived performance.
import { Suspense } from "react";
export default function Page() {
return (
<>
<Header /> {/* flushed immediately */}
<Suspense fallback={<Spinner />}>
<SlowFeed /> {/* streams in when its data resolves */}
</Suspense>
</>
);
}
Next.js App Router#
The app/ directory is file-system routed. Special files: page.jsx (route UI), layout.jsx (shared, nested, persistent wrapper), loading.jsx (Suspense fallback), error.jsx (error boundary), route.js (API handler). Components are Server Components by default.
| Rendering | How | Use for |
|---|---|---|
| SSR | Rendered per request | Personalized/dynamic pages |
| SSG | Rendered at build time | Static content (marketing, docs) |
| ISR | Static + periodic revalidate |
Mostly-static that updates occasionally |
Control it per-fetch/segment: fetch(url, { cache: "force-cache" }) (static/SSG), { cache: "no-store" } (SSR per request), or { next: { revalidate: 60 } } (ISR).
// app/dashboard/layout.jsx — wraps all nested routes, preserved across navigations
export default function DashboardLayout({ children }) {
return <section><Sidebar />{children}</section>;
}
Server actions#
"use server" marks a function that runs only on the server, callable from a form or client component — no manual API route. Great for mutations; pair with revalidatePath/revalidateTag to refresh cached data.
// app/actions.js
"use server";
import { revalidatePath } from "next/cache";
export async function addTodo(formData) {
await db.todo.create({ data: { text: formData.get("text") } });
revalidatePath("/todos"); // refresh the cached page
}
// in a Server Component:
<form action={addTodo}>
<input name="text" />
<button>Add</button>
</form>
🎯 Server actions run on the server with access to secrets/DB, are invoked via a form action or from client code, and integrate with the App Router cache — but validate/authorize inside them; they're a public endpoint.
Interview Q&A#
Q1. What can't a Server Component do?
Use state, effects, refs, event handlers, or browser APIs — it doesn't run or hydrate on the client. For interactivity you cross into a Client Component via "use client".
Q2. What does "use client" actually do?
It marks a module (and its import subtree) as a Client Component boundary — that code is bundled and hydrated in the browser. It's a boundary directive, not a per-component annotation.
Q3. Why do RSCs reduce bundle size?
Their code never ships to the client — only the rendered output (RSC payload). Only components inside a "use client" boundary send JavaScript, so keeping interactivity at the leaves minimizes bundle size.
Q4. SSR vs SSG vs ISR in Next.js App Router?
SSR renders per request (dynamic); SSG renders at build time (static); ISR serves static output but revalidates on an interval. In App Router you choose via fetch cache/revalidate options per segment.
Q5. What is a server action?
A "use server" function that executes only on the server, callable from a form action or client code without a hand-written API route. Ideal for mutations, and it can revalidate cached routes — but must authorize its own inputs.