Lesson 6 of 41
Server Components and Server Actions
The server/client boundary as a first-class part of the component tree — components that run only on the server, actions that run only on the server, and the new design trade-offs.
Every lesson so far in this track has treated "server rendering" as a timing question — when is the HTML produced, and by what? React Server Components (RSC) and Server Actions change that model: the server/client boundary becomes a structural property of the component tree. Some components run only on the server, forever; others run on both. Some functions run only on the server; the client can call them without writing a line of HTTP glue.
This lesson is about the trade-offs that change when the boundary is first-class rather than a deployment detail.
Server Components, concretely
A Server Component is a component whose source code never reaches the browser. It renders once on the server, produces HTML and a serialized description of its children, and those bytes cross the network. The component's file, the libraries it imported, the data clients it used — none of that is in the browser's bundle.
A canonical example:
// app/products/[id]/Hero.tsx — a Server Component by default in RSC-enabled frameworks
import { db } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; // 2MB
import { AddToCartButton } from "./AddToCartButton"; // a Client Component
export default async function Hero({ id }: { id: string }) {
const product = await db.product.findUnique({ where: { id } });
return (
<section>
<h1>{product.name}</h1>
<img src={product.image} alt="" />
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(product.description) }} />
<p>{product.priceFormatted}</p>
<AddToCartButton id={product.id} />
</section>
);
}What ships to the browser from this file:
- The HTML of the rendered output.
- A serialized reference to
AddToCartButtonso React can hydrate it. - Zero bytes of
Hero's source,db, the database driver,renderMarkdown, or the 2MB markdown renderer.
A naive SPA would have shipped all of that. A classic SSR setup would have shipped all of that and re-executed it in the browser during hydration. RSC ships none of it and executes none of it in the browser — ever.
What a Server Component can't do
The restriction list is the shape of the design:
- No state. No
useState, nouseReducer. - No effects. No
useEffect. - No event handlers on the rendered output. No
onClick,onChange, etc. - No browser-only APIs. No
localStorage, nowindow, nodocument.
The moment you need any of those, you're in Client Component territory. Opting in is one directive:
// AddToCartButton.tsx
"use client";
import { useState } from "react";
export function AddToCartButton({ id }: { id: string }) {
const [adding, setAdding] = useState(false);
return (
<button onClick={() => { setAdding(true); /* ... */ }}>
{adding ? "Adding..." : "Add to cart"}
</button>
);
}A Client Component works like the React you already know: it renders once on the server (for SSR) and then hydrates and re-renders on the client.
The composition rules worth internalising
- Step 1
Server renders into Client
A Server Component can render a Client Component as a child. This is the common case — a mostly-static Server Component page with a few interactive islands.
- Step 2
Client can render Server — but only via children
A Client Component can accept a Server Component as a child via props (typically `children`). It can't import one and render it, because the Client Component's code runs in the browser, where the Server Component's code doesn't exist.
- Step 3
Props crossing the boundary must be serializable
Pass strings, numbers, booleans, plain objects, arrays, Dates, Maps, Sets. Not functions (except Server Actions), not class instances, not DOM nodes.
The "Client can accept Server as children" rule is the one most worth internalising. It's how a dark-mode-toggling Client Component can wrap mostly-static Server content without that content having to be moved into the client bundle.
Server Actions: mutations as functions, not endpoints
A Server Action is a function marked 'use server' that the
framework exposes as if it were a local function call, but which
actually runs on the server. The split is done at build time: the
server keeps the function body; the client gets a thin stub that
posts a serialized call over HTTP.
// actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function addComment(postId: string, body: string) {
await db.comment.create({ data: { postId, body } });
revalidatePath(`/posts/${postId}`);
}// CommentForm.tsx
"use client";
import { addComment } from "./actions";
export function CommentForm({ postId }: { postId: string }) {
return (
<form action={async (formData) => {
await addComment(postId, formData.get("body") as string);
}}>
<textarea name="body" />
<button type="submit">Post</button>
</form>
);
}What this replaces, line by line:
- The
POST /api/commentsendpoint you'd have handwritten. - The
fetch("/api/comments", { method: "POST", body: JSON.stringify(...) })in the form submit handler. - The response parsing, error handling, and type gymnastics across the boundary.
- The manual "now re-fetch the comments list" step after a
successful mutation —
revalidatePathtells the framework which cached route to throw away, and the Server Components under that route re-run automatically.
The HTTP round trip still happens. What goes away is the glue code. From the component's point of view, calling the server is just calling a function.
Design trade-offs this creates
The architecture has real wins and real costs. The honest list:
Wins:
- Bundle size drops — often dramatically — because rendering code that only ran on the server used to ship to the browser for hydration. It no longer does.
- Data fetching is colocated. The component that needs the data fetches it, next to the markup, with the same async/await you'd write in a server script. No separate GraphQL layer or REST endpoint "just for the frontend."
- Mutations are functions. Server Actions eliminate the category of bugs that lived in manually-written API endpoints (shape drift, serialization errors, double-wrapped JSON responses).
Costs:
- A mental model to learn. The "which side does this run on?" question is a new one, and the compiler errors when you get it wrong are the kind that take a while to read fluently.
- Not all libraries are safe on the server. A library that
pokes
windowon import won't work in a Server Component, which means some ecosystem code either needs a wrapper or has to live in a Client Component. - Framework lock-in is higher. Server Components are not a pure React feature yet — they require a framework (Next.js, Remix 3, or your own RSC-aware bundler) to compile and serve them. Switching frameworks is more work than it used to be when "React" meant "a single bundle in a Vite project."
When Server Components earn their cost
Three concrete shapes where they're the obvious call:
- Content-heavy pages. A blog post, a product page, a docs page. Mostly-static rendering with a few interactive islands is exactly what Server Components are designed for, and the bundle- size reduction is the biggest.
- Dashboards with a lot of server-side data. Executing SQL from a Server Component next to the JSX that renders it beats the "GraphQL endpoint → React Query → component" chain for simple cases.
- Admin surfaces. Internal tools often have mutations as the main interaction shape. Server Actions make "edit this row → save → see the update" something you write as one function, not a REST handler plus a mutation hook.
Three shapes where it's genuinely not a fit:
- Pure SPAs with no server you control. No server, no Server Components. This is what Vite + React still shines at.
- Apps where the client bundle is already tiny. The RSC bundle- size win is proportional to how much rendering code you used to ship. If you're already at a few dozen KB, it's not the improvement to chase.
- Realtime-first apps. Chat, multiplayer canvas, anything that mostly reads from a live socket. Server Components render once at request time; everything realtime happens on the client anyway.
What to remember
- A Server Component runs only on the server. Its source, its dependencies, and its data-access code stay on the server. What ships is HTML plus a serialized tree describing where Client Components sit.
- A Server Component cannot have state, effects, event handlers, or touch browser APIs. The moment you need those, use a Client Component (
'use client'directive at the top of the file). - Composition rules: Server can render Client; Client can accept Server via children (not import and render it directly). Props crossing the boundary must be serializable.
- A Server Action is a server function the client can call as if it were local. The framework splits the call at build time and handles the HTTP transport + cache revalidation. It replaces the handwritten API endpoint for most mutations.
- Wins: smaller bundles, data fetching next to markup, fewer custom API endpoints. Costs: a new mental model, some ecosystem friction, higher framework lock-in.
- Best fit: content-heavy pages, data-heavy dashboards, admin surfaces. Worst fit: pure SPAs without a server, apps whose client bundle was already small, realtime-first apps.
Check yourself
4 questions · pass 3/4 to unlock Edge Rendering — the Honest Trade-offs
1.What is a Server Component, in the sense used by current React / Next.js?
2.Which of these is *not* something a Server Component can do?
3.A product page uses a Server Component for its hero section (title, image, description, price — read from the DB) and a Client Component for the 'Add to cart' button. What specifically travels over the wire?
4.A Server Action is a form submit handler marked
'use server'on the server side. What makes it different from a classicPOST /api/do-thingendpoint plus a fetch call?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.