React Server Components & Server Actions
Reviewed & published by Brayan K
React Server Components (RSC) are components whose code runs on the server and never ships to the browser. They render once, on the server, and stream a description of the UI to the client — so they can read your database or filesystem directly, pull in heavy libraries with zero bundle cost, and hand finished data to small interactive Client Components marked with "use client". Server Actions are the write side of the same model: async functions marked "use server" that you can wire straight to a <form> to mutate data without hand-building an API. By the end you'll know exactly which code runs where, why it makes apps faster, and how the Next.js App Router puts it all together.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- What Server vs Client Components actually are
- Where the "use client" boundary goes — and why
- Why RSC ship less JavaScript to the browser
- Fetching data directly inside a Server Component
- Writing Server Actions with "use server"
- Binding an action to a <form action={fn}>
- How the Next.js App Router wires it all up
- Common mistakes (hooks in server code, leaking secrets)
1️⃣ Server vs Client: Where Code Runs
In a classic React app, every component ships to the browser. RSC splits that world in two. A Server Component runs once on the server and its source never reaches the client. A Client Component — anything that needs useState, useEffect, or an event handler — opts in with the "use client" directive at the top of the file.
// app/article/page.tsx — NO directive => Server Component (the default)
import db from "@/lib/db"; // heavy DB client: stays on the server
import { LikeButton } from "./LikeButton";
export default async function ArticlePage() {
// Look ma, no useEffect: a Server Component can just await data.
const article = await db.article.find({ slug: "rsc" });
return (
<article className="prose">
<h1>{article.title}</h1>
<p>{article.body}</p>
<LikeButton articleId={article.id} initialLikes={article.likes} />
</article>
);
}// app/article/LikeButton.tsx
"use client"; // <- this file is a Client Component
import { useState } from "react";
export function LikeButton({ articleId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes); // state needs the client
return (
<button onClick={() => setLikes((n) => n + 1)}>
❤️ {likes}
</button>
);
}The demo below models the resolver the framework runs over your component tree: walk it, and once you cross a "use client" marker, everything below it is client too. Run it and read the placements:
// We can't run a real server here, but the KEY idea of Server Components is
// a boundary: some modules run on the SERVER, some ship to the CLIENT.
// Let's model the resolver the framework runs: walk the component tree and
// decide where each component executes, based on the "use client" marker.
const tree = {
name: "Page", directive: null, // default => server
children: [
{ name: "ArticleBody", directive: null }, // server
{ name: "LikeButton", directive: "use client" },// client boundary!
{ name: "Sidebar", directive: null,
children: [
{ name: "Newsletter", directive: "use client" },
],
},
],
};
// Once you cross a "use client" boundary, everything below it is client too.
function placeRunsOn(node, parentEnv) {
const env = node.directive === "use client" ? "client" : parentEnv;
const rows = [[node.name, env]];
for (const child of node.children ?? []) {
rows.push(...placeRunsOn(child, env));
}
return rows;
}
for (const [name, env] of placeRunsOn(tree, "server")) {
console.log(name.padEnd(12), "=>", env);
}
// Page => server
// ArticleBody => server
// LikeButton => client
// Sidebar => server
// Newsletter => client2️⃣ Why RSC Exist: Less JS, Direct Data
Two payoffs drive the whole design. First, less JavaScript: the code and libraries used only to fetch and shape data never go to the browser, so visitors download a fraction of what a fully client-rendered app would send. Second, direct data access: a Server Component can await your database, read the filesystem, or call an internal service with no client-side fetch, no loading spinner, and no extra round trip.
📊 The two worlds side by side
| Concern | Client-fetched component | Server Component |
|---|---|---|
| Data fetching | useEffect + fetch | await db.query() |
| Loading state | manual isLoading | <Suspense> / loading.tsx |
| Bundle cost of libs | shipped to browser | zero — stays server-side |
| Secrets / API keys | risky to expose | safe on the server |
| Round trips to render | HTML → JS → fetch → data | data already in the HTML |
// The OLD way (client component): three states, a round trip, libs in the bundle
"use client";
import { useEffect, useState } from "react";
function Dashboard() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetch("/api/stats").then((r) => r.json()).then(setData).catch(setError);
}, []);
if (error) return <p>Failed to load</p>;
if (!data) return <p>Loading…</p>; // user stares at a spinner
return <Stats data={data} />;
}
// The RSC way: no spinner, no /api route, no fetch — just data.
async function Dashboard() { // a Server Component
const data = await getStats(); // runs on the server
return <Stats data={data} />;
}3️⃣ Server Actions: Mutations Without an API
Reading data on the server is half the story. To write data you use a Server Action: an async function marked with the "use server" directive. The framework lets you call it from your components — most powerfully by passing it straight to a form's action prop. When the form submits, React serializes the inputs, runs your function on the server, and you mutate data and revalidate the UI with no hand-written fetch or route handler in sight.
// app/todos/actions.ts
"use server"; // every export here is a Server Action
import db from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function addTodo(formData: FormData) {
const text = String(formData.get("text") ?? "").trim();
if (!text) return; // validate on the server — never trust input
await db.todo.create({ data: { text, done: false } });
revalidatePath("/todos"); // refresh the list that just changed
}// app/todos/page.tsx — a Server Component that renders the form
import db from "@/lib/db";
import { addTodo } from "./actions";
export default async function TodosPage() {
const todos = await db.todo.findMany(); // direct data access
return (
<>
<form action={addTodo}>
<input name="text" placeholder="New todo" />
<button type="submit">Add</button>
</form>
<ul>{todos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
</>
);
}The demo models the action lifecycle every Server Action follows — validate → mutate → revalidate — without needing a real database. Run it and watch an empty submission get rejected while a real one goes through:
// A Server Action is an async function that runs on the server and mutates
// data, then tells the framework to refresh the affected UI. We can't touch a
// real database here, so we model the lifecycle: validate -> mutate -> revalidate.
const db = { todos: [{ id: 1, text: "Learn RSC", done: false }] };
let nextId = 2;
// Imagine this file starts with "use server" so the whole module is actions.
async function addTodo(formData) {
// 1) read + validate the submitted form value (never trust the client)
const text = (formData.text ?? "").trim();
if (!text) return { ok: false, error: "Text is required" };
// 2) mutate on the server (here: push; in real life: INSERT into the DB)
db.todos.push({ id: nextId++, text, done: false });
// 3) revalidate: tell the framework the /todos data changed so it re-renders
return { ok: true, revalidated: "/todos", count: db.todos.length };
}
async function run() {
console.log(await addTodo({ text: "" })); // rejected, no text
console.log(await addTodo({ text: "Ship the app" })); // accepted
console.log("todos now:", db.todos.map((t) => t.text));
}
run();
// { ok: false, error: 'Text is required' }
// { ok: true, revalidated: '/todos', count: 2 }
// todos now: [ 'Learn RSC', 'Ship the app' ]4️⃣ How the Next.js App Router Uses RSC
The Next.js App Router (the app/ directory) is built on RSC from the ground up. Every file in it is a Server Component by default; you reach for "use client" only at the interactive leaves. A few special files do a lot of work:
| File | Role |
|---|---|
| page.tsx | The route's UI — a Server Component unless you opt out |
| layout.tsx | Shared shell that wraps child routes; persists across navigation |
| loading.tsx | Instant Suspense fallback while a Server Component awaits data |
| error.tsx | Error boundary for the segment (must be a Client Component) |
| actions.ts | A module of "use server" mutations for that feature |
app/
├─ layout.tsx # root shell (server)
├─ page.tsx # "/" home (server)
└─ todos/
├─ page.tsx # "/todos" — awaits the DB directly (server)
├─ loading.tsx # shown instantly while the page streams in
├─ actions.ts # "use server" — addTodo, toggleTodo, deleteTodo
└─ TodoItem.tsx # "use client" — the one piece that needs onClick🎯 Your Turn: Decide Where Code Lives
Fill in the blanks so the rules match how React decides server vs client. The core instinct: interactivity needs the client; plain rendering can stay on the server.
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Rule of thumb: state, effects, and event handlers need the CLIENT.
// Plain data-fetching + rendering can stay on the SERVER.
function canUseState(env) {
// 1) useState only works in the browser runtime.
// Return true only when env is the string "client".
return env === ___; // 👉 "client"
}
function whereShouldItLive(needsInteractivity) {
// 2) If a component needs clicks/state, it must be a client component.
// Otherwise, default to the server.
return ___ ? "client" : "server"; // 👉 needsInteractivity
}
console.log(canUseState("server")); // false
console.log(canUseState("client")); // true
console.log(whereShouldItLive(true)); // client (a button)
console.log(whereShouldItLive(false)); // server (an article)
// ✅ Expected output:
// false
// true
// client
// serverReorder Challenge
These lines of a Server Action handler are scrambled. Put them in the correct order:
A. await db.todo.create({ data: { text } });
B. export async function addTodo(formData) {
C. const text = String(formData.get("text") ?? "").trim();
D. revalidatePath("/todos");
E. if (!text) return;
F. }Quick Recall
1) A file has no directive at the top in the App Router. Server or client?
Server Component — that's the default. You only become a Client Component by adding "use client" to the file (or importing a module that has it).
2) Your component needs onClick. What must you add?
Put "use client" at the very top of the file. Event handlers and state need the browser runtime, so that component must be a Client Component.
3) Why does moving data fetching to a Server Component shrink the bundle?
The fetching/parsing libraries and the component's data code run only on the server and are never sent to the browser. Visitors download only the small interactive pieces.
4) What three steps does a typical Server Action perform?
Validate the input, mutate the data on the server, then revalidate (e.g. revalidatePath) so the affected UI re-renders with fresh data.
Common Errors (and the fix)
- Using a hook in a Server Component: useState/useEffect throw because there's no client runtime. Move the interactive part into a "use client" child.
- Putting "use client" on the whole page: that drags the entire subtree to the client and kills the benefit. Mark the smallest interactive leaf instead.
- Passing a function as a prop across the boundary: you can't send a non-serializable value (like an event handler) from a Server Component into a Client one. Pass data; let the client own its handlers.
- Forgetting to revalidate after a mutation: the data changes but the page still shows the old list. Call revalidatePath/revalidateTag in the action.
- Reading a secret in a Client Component: it ships to the browser. Keep secrets and the code that uses them on the server.
📋 Quick Reference
| You want to… | Do this |
|---|---|
| Default a component to the server | add no directive (App Router) |
| Use state / effects / onClick | "use client" at top of file |
| Fetch data on the server | async function + await |
| Mutate data | "use server" action + <form action={fn}> |
| Refresh UI after a write | revalidatePath("/route") |
| Show a loading fallback | loading.tsx + <Suspense> |
Mini-Challenge: Tag Each Component
Finish classify so each component gets labeled "client" if it needs interactivity, otherwise "server" — the exact call you make on every component you write.
// Each component declares whether it needs interactivity (state/clicks).
const components = [
{ name: "BlogPost", interactive: false },
{ name: "SearchBox", interactive: true },
{ name: "PriceTable", interactive: false },
{ name: "AddToCartBtn", interactive: true },
];
// Return "client" when it needs interactivity, otherwise "server".
function classify(c) {
return c.interactive ? "client" : "server";
}
for (const c of components) {
console.log(c.name.padEnd(13), "=>", classify(c));
}
// Which files need the "use client" directive?
const needsDirective = components
.filter((c) => classify(c) === "client")
.map((c) => c.name);
console.log("Need \"use client\":", needsDirective.join(", "));🎉 Lesson Complete
- ✅ Server Components run on the server and never ship their code to the browser
- ✅ "use client" marks the boundary for state, effects, and events
- ✅ RSC means less JavaScript and direct data access — no /api round trip
- ✅ Server Actions ("use server") mutate data and bind to <form action={fn}>
- ✅ The Next.js App Router is server-first; opt in to the client at the leaves
Practice quiz
What is the defining characteristic of a React Server Component (RSC)?
- It always renders faster than a Client Component
- It runs only after the page has hydrated
- Its code runs on the server and never ships to the browser
- It can use useState and useEffect freely
Answer: Its code runs on the server and never ships to the browser. An RSC runs once on the server, produces a serialized description of UI, and its source is never sent to the client.
In a framework built around RSC (like the Next.js App Router), what is the default for a component with no directive?
- A Server Component
- A Client Component
- Neither until you choose
- It depends on whether it uses JSX
Answer: A Server Component. Server is the default. A file becomes a Client Component only when it (or a module it imports for rendering) is marked "use client".
Which directive marks a file as a Client Component?
- "use server"
- "use strict"
- "client only"
- "use client"
Answer: "use client". "use client" at the top of a file opts that module and its render imports into the client. The boundary flows downward.
Can a Server Component use useState, useEffect, or onClick?
- Yes, all of them
- No — those need the browser runtime, so move them into a "use client" child
- Only useEffect
- Only when wrapped in Suspense
Answer: No — those need the browser runtime, so move them into a "use client" child. Server Components have no client runtime: no state, effects, or event handlers. Push interactivity down into a small Client Component.
Where should you place the "use client" directive to keep the most code on the server?
- As low in the tree as possible, on the small interactive leaf
- On the root layout so everything is consistent
- On every file to be safe
- On the page that fetches data
Answer: As low in the tree as possible, on the small interactive leaf. The boundary flows downward, so marking a high component drags its whole subtree to the client. Mark the smallest interactive leaf instead.
How does a Server Component typically fetch data?
- useEffect + fetch with a loading flag
- It calls an /api route from the browser
- It awaits the data directly (e.g. await db.query()) since it's an async function on the server
- It cannot fetch data at all
Answer: It awaits the data directly (e.g. await db.query()) since it's an async function on the server. A Server Component can be async and await the database or an internal service directly — no client fetch, no spinner, no extra round trip.
Why does moving data fetching into a Server Component shrink the client bundle?
- React compresses server code more aggressively
- The fetching/parsing libraries run only on the server and are never sent to the browser
- It disables source maps
- It removes the need for React on the client
Answer: The fetching/parsing libraries run only on the server and are never sent to the browser. The DB client, parsers, and SDKs used to shape data stay server-side. Visitors download only the small interactive pieces.
What is a Server Action?
- A client-side event handler
- A special kind of useEffect
- A Next.js middleware file
- An async function marked "use server" that runs on the server and can be bound straight to a <form action={fn}>
Answer: An async function marked "use server" that runs on the server and can be bound straight to a <form action={fn}>. Server Actions are the write side of RSC: "use server" functions you can call from components (often via a form's action prop) to mutate data without a hand-built API.
What three steps does a typical Server Action perform?
- Render, hydrate, paint
- Validate the input, mutate the data, then revalidate the affected UI
- Fetch, cache, stream
- Connect, query, disconnect
Answer: Validate the input, mutate the data, then revalidate the affected UI. Validate (never trust the client), mutate on the server, then revalidate (e.g. revalidatePath) so the affected route re-renders with fresh data.
Why is it safe to read process.env.DATABASE_URL inside a Server Component?
- React encrypts environment variables
- Because env vars are always public
- Because Server Component code never reaches the browser, so secrets aren't exposed to visitors
- It isn't safe under any circumstances
Answer: Because Server Component code never reaches the browser, so secrets aren't exposed to visitors. Server-only code is a real security boundary. Reading a secret in a Client Component would ship it to every visitor's dev tools.
Continue this course
- Previous: Next.js & the App Router
- Next: React 19 Hooks: use(), useActionState & useOptimistic — The newest React primitives for promises, form state, and optimistic UI
- Quick reference: React cheat sheet