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

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   => client

2️⃣ 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

ConcernClient-fetched componentServer Component
Data fetchinguseEffect + fetchawait db.query()
Loading statemanual isLoading<Suspense> / loading.tsx
Bundle cost of libsshipped to browserzero — stays server-side
Secrets / API keysrisky to exposesafe on the server
Round trips to renderHTML → JS → fetch → datadata 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:

FileRole
page.tsxThe route's UI — a Server Component unless you opt out
layout.tsxShared shell that wraps child routes; persists across navigation
loading.tsxInstant Suspense fallback while a Server Component awaits data
error.tsxError boundary for the segment (must be a Client Component)
actions.tsA 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
//    server

Reorder 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)

📋 Quick Reference

You want to…Do this
Default a component to the serveradd no directive (App Router)
Use state / effects / onClick"use client" at top of file
Fetch data on the serverasync function + await
Mutate data"use server" action + <form action={fn}>
Refresh UI after a writerevalidatePath("/route")
Show a loading fallbackloading.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

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

Related lessons