React Server Components
RSC are React components that run only on the server, render to a special serialized format streamed to the client, and never ship their code or dependencies to the browser. Mix with Client Components (`"use client"`) for interactivity. Benefits: zero JS for static parts, direct DB access, secrets stay server-side, automatic streaming. Trade-off: a new mental model — you can't pass functions or class instances across the boundary.
RSC change the default. Before: every component runs on both server (for SSR HTML) and client (for hydration). RSC: components run only on the server unless explicitly marked client-side.
The mental model
Server Component (default) Client Component ("use client")
├─ Runs only on server ├─ Runs on server AND client
├─ Direct DB / fs / env access ├─ Hooks: useState, useEffect, useContext
├─ NO useState / useEffect ├─ Event handlers
├─ NO event handlers ├─ Browser-only APIs
├─ NO browser-only APIs ├─ Ships JS to the browser
└─ Ships NO JS to the browser └─ Hydrates and becomes interactive// app/posts/page.tsx — Server Component
import { db } from "@/lib/db";
import LikeButton from "./LikeButton";
export default async function PostsPage() {
const posts = await db.post.findMany(); // direct DB query, no API layer
return posts.map(p => (
<article key={p.id}>
<h2>{p.title}</h2>
<p>{p.body}</p>
<LikeButton postId={p.id} initialLikes={p.likes} />
</article>
));
}// app/posts/LikeButton.tsx — Client Component
"use client";
import { useOptimistic, useTransition } from "react";
import { like } from "./actions";
export default function LikeButton({ postId, initialLikes }) {
const [count, addOptimistic] = useOptimistic(initialLikes);
const [pending, start] = useTransition();
return (
<button onClick={() => start(async () => {
addOptimistic(count + 1);
await like(postId);
})}>
❤ {count} {pending && "…"}
</button>
);
}The page is server-rendered with full content. The like button hydrates as an island — interactive, small JS payload.
The wire format
Server Components render to a JSON-like stream (the "RSC payload"). The client receives it incrementally and reconstructs the tree. Client Components within it hydrate independently. Suspense boundaries can stream — content arrives as it's ready, not all-or-nothing.
What you can / can't pass across the boundary
Server → Client props must be serializable.
✓ primitives, objects, arrays, Dates (in some impls), promises (! — server promise becomes a client promise via streaming) ✗ functions (except special "server actions"), class instances, Map/Set (sometimes), DOM nodes, Symbols.
If you need to "pass a function" to a Client Component, two options:
- Server Action — a function marked
"use server"that the client can call (it actually executes on the server). - Compose — pass a server-rendered subtree as a
childrenprop and let the Client Component just position it.
// composition: ClientCard wraps server-rendered content
<ClientCard>
<ServerExpensiveData />
</ClientCard>The Client Component re-renders interactively; the server-rendered tree inside stays as static markup.
Server Actions
// actions.ts
"use server";
export async function like(postId: string) {
await db.post.update({ where: { id: postId }, data: { likes: { increment: 1 } }});
}Called from a Client Component, dispatched as an RPC to the server, executes there. Eliminates writing API routes for one-off mutations. Use Zod to validate inputs.
Streaming + Suspense
export default function Page() {
return (
<>
<FastHeader />
<Suspense fallback={<Skeleton />}>
<SlowFeed />
</Suspense>
<FastFooter />
</>
);
}The header and footer flush early; the feed streams in when its data resolves. Users see content as it's ready, not after the slowest query.
What changes for the developer
Wins.
- Smaller JS bundles. Static content ships zero JS.
- Direct data access. No HTTP round trip from server-component to API; query the DB directly.
- Secrets on the server. API keys, DB credentials never reach the client.
- Faster initial paint. Server renders → stream → client hydrates only the islands.
- Co-located data. A page-level component fetches its own data; no global cache to coordinate.
Costs.
- New mental model — figuring out what runs where, which props are serializable.
- More complex error handling — server errors need to convert to client-displayable shapes.
- Caching choices proliferate — Next.js has request cache, full route cache, etc.
- Build / framework lock-in — Next.js App Router, Waku, soon others. Not portable like pure React.
Common pitfalls
- Adding "use client" too high in the tree. Defeats RSC — everything below becomes client. Push the boundary down to the actual interactive leaves.
- Importing client-only libs in server components. Server crashes at build/runtime. Wrap interactive libs in a
"use client"boundary.
- Trying to use hooks in server components. They don't run on the client; useState/useEffect don't make sense.
- Mixing data-fetching patterns. TanStack Query inside Server Components is nonsensical (no client). RSC fetches directly; TanStack handles client-driven refetches.
- Server Action security. They're RPC endpoints. Validate inputs. Authenticate the user. Don't trust the client.
When RSC isn't worth it
- Tightly-interactive apps (editors, dashboards with mostly dynamic content) — most components are client anyway; the gain is small.
- Need to ship without a server (static export, SPA) — RSC requires a server runtime.
- Existing apps with deep state-management coupling — migration cost is high.
Senior framing
The interviewer is testing whether the candidate (1) understands the boundary between server and client and what crosses it, (2) knows when to use Server Actions vs client mutations, (3) understands streaming + Suspense, (4) recognizes when not to use RSC. The "I use Next.js App Router" answer is mid; demonstrating the boundary discipline and trade-offs is senior.
Follow-up questions
- •What can't be serialized across the server/client boundary?
- •When do you reach for a Server Action vs a Client Component fetch?
- •How does streaming change the perceived load?
- •When is RSC the wrong choice?
Common mistakes
- •Marking pages 'use client' wholesale — eliminates the benefit.
- •Importing browser-only libs in server components.
- •Trusting client input in server actions without validation.
- •Trying to use useState in a server component.
Performance considerations
- •Server components ship zero JS for their part of the tree.
- •Streaming lets fast parts of the page appear before slow data resolves.
- •Pay attention to the framework's caching layers — they can be subtle.
Edge cases
- •Hydration mismatches when server and client compute different values (Date.now, random).
- •Server actions with optimistic UI need both sides aware of the operation.
- •Cookies / headers access requires framework-specific APIs (next/headers).
Real-world examples
- •Next.js App Router (the canonical implementation).
- •Waku — minimal RSC framework.
- •Vercel, Notion, parts of GitHub adopting RSC for content-heavy pages.