webdev.complete
🖥️ Server Components & Actions
🚀Next.js & T3
Lesson 93 of 117
30 min

Server Components Explained

use client, use server, the network boundary in JSX.

For a decade, React lived entirely in the browser. Then 2023 happened, and the framework grew a second home: the server. React Server Components (RSC) are not server-side rendering. They're not getServerSideProps. They're a new kind of component that only runs on the server, ships zero JavaScript to the client, and composes seamlessly with the interactive components you already know. In Next.js App Router, Server Components are the default. Once that sinks in, the whole architecture clicks.

Two kinds of component, one tree

Every component in an App Router app is either a Server Component or a Client Component. The rules are simple:

  • Server Components are the default. They run on the server (during request or at build time), can be async, can read files, query databases, hit private APIs. They don't ship JavaScript to the browser.
  • Client Components opt in with the directive "use client" at the top of the file. They run in the browser (and during SSR), can use hooks, state, effects, and event handlers.
app/products/page.tsx (Server)
// no directive needed; Server is the default
import { db } from "@/server/db";
import { AddToCartButton } from "./add-to-cart";

export default async function ProductsPage() {
  const products = await db.product.findMany();
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}
          <AddToCartButton id={p.id} />
        </li>
      ))}
    </ul>
  );
}
app/products/add-to-cart.tsx (Client)
"use client";

import { useState } from "react";

export function AddToCartButton({ id }: { id: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added!" : "Add to cart"}
    </button>
  );
}
The directive is contagious
"use client" marks an entry pointinto the client. Every component this file imports (and they import) becomes a client component too. So you only put the directive on the top of a tree, not everywhere.

What can run where?

The boundary is sharp. Knowing what each side can do prevents most RSC bugs.

Server Components can

  • Be async and use await directly in the body
  • Read files, env secrets, query databases
  • Import server-only libraries (Node SDKs, ORMs)
  • Render Client Components as children

Server Components cannot

  • Use useState, useEffect, or any hook
  • Add event handlers like onClick
  • Use browser APIs (window, localStorage)
  • Subscribe to context that's only set up on the client

Client Components can

  • Use all React hooks
  • Attach event handlers, read user input
  • Access window (after mount)
  • Render Server Components only as children, never by importing them

The serializable props rule

Server Components run on the server and produce a payload that gets shipped to the browser. Any prop that crosses from server to client has to fit in that payload, which means it must be serializable. JSON-compatible values work: strings, numbers, booleans, arrays, plain objects, null. Functions do not.

tsx
// ✓ Fine
<ClientThing user={{ name: "Ada", age: 36 }} />

// ✗ Will throw at runtime
<ClientThing onSubmit={(data) => savePost(data)} />
Server Actions are the exception
Functions marked with "use server" can be passed across the boundary. Under the hood Next replaces them with a serialized reference that calls back to the server. More on those in the next lesson.

Composition: server inside client

Client Components can't import Server Components, but they can render them as children. This is how you get a fancy interactive shell with server-rendered guts.

app/layout.tsx (Server)
import { Sidebar } from "./sidebar";        // client
import { Feed } from "./feed";              // server

export default function Layout() {
  // Sidebar is a client component, but Feed (server) is passed
  // as children; the server still renders Feed.
  return (
    <Sidebar>
      <Feed />
    </Sidebar>
  );
}
app/sidebar.tsx (Client)
"use client";

import { useState } from "react";

export function Sidebar({ children }: { children: React.ReactNode }) {
  const [collapsed, setCollapsed] = useState(false);
  return (
    <div>
      <button onClick={() => setCollapsed((c) => !c)}>Toggle</button>
      {!collapsed && children}
    </div>
  );
}

Read that twice. Sidebar is interactive, butFeedstill does its database query on the server. The two coexist in one tree. The pattern of "pass server JSX as children" is the most common RSC pattern in real apps.

Mental model: what arrives in the browser

Open DevTools. Look at what actually gets sent. For a typical RSC page you'll see two things:

  1. HTML: the fully rendered page, like traditional SSR, so the first paint is fast and search engines see it.
  2. RSC payload: a streaming text format that describes the React tree, including references to the Client Components that need to hydrate. JavaScript bundles get loaded only for those Client Components.

Server-only components literally never become JavaScript bundles. A 50KB markdown parser used inside a Server Component adds zero bytes to your client JS. That's the perf story.

When to choose which

  • Need useState or onClick Client.
  • Need to await a database or read a secret → Server.
  • Both? Split the component. Keep the server parent as default and extract the interactive bit into a smaller client child. This is the pattern.
Default to server, opt into client
New developers reach for "use client"at the top of every file because that's how React used to work. Resist. Smaller client islands ship less JavaScript and stay faster. Only opt in where you genuinely need interactivity.

Quiz

Quiz1 / 3

Which is the default in the Next.js App Router?

Recap

  • App Router defaults to Server Components; opt into client with "use client" at the top of the file.
  • Server Components ship zero JS, can be async, and can read servers/databases directly.
  • Client Components own state, effects, and event handlers.
  • Props across the boundary must be serializable; functions don't cross (except Server Actions).
  • Compose by passing server JSX as children into client components.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.