Overview

React Server Components have been the most confusing part of React's evolution, partly because the explanations keep mixing them up with Server-Side Rendering, which is a different thing. They solve a specific problem — shipping too much JavaScript to the browser for content that doesn't need it — and once you see what they replace, the design makes more sense.

This is what changed for me after building a couple of applications with them.

Server Components vs SSR

SSRServer Components
Runs whereServer, then hydrates in browserServer only
Ships JS to browserYes, the whole componentNo, only the rendered output
Can use hooks/stateYes, after hydrationNo, not for state or effects
Access to DatabaseOnly in data-loading phaseDirectly in the component
Re-renders on interactionYes, in browserNo, only on navigation

The key difference: with SSR, the server renders HTML, but the browser then downloads and runs the same component. With Server Components, the component runs on the server and the browser never sees it. Only the rendered output is sent.

This is why a page that fetches data from a database and renders a list can ship almost no JavaScript for the list itself. The component never runs in the browser.

The mental model

Every component is either a Server Component or a Client Component, and you opt into client with a directive.

// This is a Server Component by default in Next.js App Router
async function ProductList() {
  const products = await db.query("SELECT * FROM products");
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>{p.name} — ${p.price}</li>
      ))}
    </ul>
  );
}

That await runs on the server, directly in the component. No useEffect, no loading state, no data fetching library. The database query and the render happen on the same machine.

To make a component interactive, add "use client":

"use client";

import { useState } from "react";

export function AddToCart({ productId }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => {
      addToCart(productId);
      setAdded(true);
    }}>
      {added ? "Added" : "Add to cart"}
    </button>
  );
}

The "use client" directive marks the boundary. Everything below it in the import tree is also a Client Component, even if you don't add the directive again.

Where the boundary goes

This is the decision that determines whether RSC helps or hurts. The rule: push the boundary as low as possible.

// Bad — makes the whole page a client component
"use client";

function ProductPage({ id }) {
  const [product, setProduct] = useState(null);
  useEffect(() => {
    fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct);
  }, [id]);

  if (!product) return <div>Loading...</div>;
  return (
    <div>
      <h1>{product.name}</h1>
      <AddToCartButton id={product.id} />
    </div>
  );
}

// Good — only the button is a client component
async function ProductPage({ id }) {
  const product = await db.products.get(id);
  return (
    <div>
      <h1>{product.name}</h1>
      <AddToCartButton id={product.id} />
    </div>
  );
}

In the second version, the page loads data on the server, renders on the server, and only the button is shipped to the browser. That's a small amount of JavaScript for the same user experience.

The mistake people make is putting "use client" at the top of a page and losing all the benefits. Once a page is a Client Component, every component it imports is too.

Passing Server Components to Client Components

You can't import a Server Component from a Client Component, but you can pass it as a child:

// Server Component
import { ClientSidebar } from "./ClientSidebar";
import { ServerContent } from "./ServerContent";

export default function Layout() {
  return (
    <ClientSidebar>
      <ServerContent />
    </ClientSidebar>
  );
}
"use client";

export function ClientSidebar({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <aside>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children}
    </aside>
  );
}

ClientSidebar is interactive, but ServerContent runs on the server. The composition works because the client component receives the server-rendered output as props, not as a component it needs to render itself.

This is the pattern that makes RSC practical. Interactive layout shells can wrap server-rendered content without forcing the whole tree to be client-side.

Server Actions

Server Actions let a client component call a function on the server. They replace the API endpoint for many use cases.

// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function createTodo(formData: FormData) {
  const title = formData.get("title") as string;
  await db.todos.create({ title });
  revalidatePath("/todos");
}
// app/todos/page.tsx
import { createTodo } from "../actions";

export default function TodosPage() {
  return (
    <form action={createTodo}>
      <input name="title" required />
      <button type="submit">Add</button>
    </form>
  );
}

The form submits to a server function. No API route, no fetch call, no client-side state. The server action runs, updates the database, and revalidation refreshes the page.

This works even with JavaScript disabled. The form submits like a normal HTML form, the server handles it, and the page reloads. Progressive enhancement comes for free.

Data fetching: no more waterfalls

Without RSC, sequential data fetches in nested components cause a waterfall: component A loads, then component B loads, then component C loads. Each waits for the previous one.

// Client Component — three sequential requests
function Page() {
  const user = useUser();           // request 1
  const posts = usePosts(user.id);  // waits for user
  const comments = useComments(posts.map(p => p.id));  // waits for posts
  // ...
}

With RSC, the components fetch in parallel because the framework knows the full tree before rendering:

// Server Components — parallel by default
async function Page() {
  return (
    <>
      <UserPanel />      {/* fetches independently */}
      <PostList />       {/* fetches independently */}
      <CommentFeed />    {/* fetches independently */}
    </>
  );
}

Each component awaits its own data, and the framework starts all of them concurrently. If you need explicit ordering, you await where needed:

async function Page() {
  const user = await getUser();
  return (
    <>
      <UserPanel user={user} />
      <PostList userId={user.id} />
    </>
  );
}

The rule: await at the top of the component for data the whole component needs, and let sub-components fetch their own data in parallel.

Streaming and Suspense

Server Components work with Suspense to stream HTML as it's ready. A slow component doesn't block the rest of the page:

import { Suspense } from "react";

async function SlowSection() {
  const data = await slowQuery();
  return <div>{data}</div>;
}

export default function Page() {
  return (
    <>
      <FastHeader />
      <Suspense fallback={<Skeleton />}>
        <SlowSection />
      </Suspense>
      <Footer />
    </>
  );
}

The header and footer render immediately. The slow section streams in when it's ready, with the skeleton shown in the meantime. This is faster than SSR without streaming, where the browser waits for the entire page to be ready.

What actually changed in my code

Before RSCAfter RSC
API routes for every page's dataDirect database access in the component
Loading states in every componentSuspense boundaries at the page level
useEffect for data fetchingAsync/await in the component
State management for cacheServer cache and revalidation
Small bundle, big fetch payloadAlmost no bundle, HTML payload

The biggest practical change: data fetching moved out of client code entirely. No more React Query for page-level data, no more loading states scattered around, no more cache invalidation on the client.

What hasn't changed

  • Client state still needs client components. Form inputs, modals, dropdowns — anything with interaction still needs "use client".
  • Third-party libraries that use hooks won't work in Server Components. Most UI libraries are client-only. You wrap them.
  • You still need a framework. RSC isn't a standalone feature. Next.js App Router is the mainstream implementation; there are others but they're less mature.
  • Debugging is harder. Errors can happen on the server, on the client, or during hydration, and the boundaries aren't always obvious.

When this is the wrong fit

If your app is a dashboard behind a login, with heavy client-side interaction, RSC doesn't help much. You'd make most of the tree client components anyway.

If your app is content-driven — a blog, a storefront, a marketing site — RSC is a large win. Most of the tree is static content that doesn't need to ship JavaScript, and the few interactive parts can be isolated.

The framework's defaults matter more than the specific API. Next.js App Router makes Server Components the default, which pushes you toward the right pattern. In an app where client components are the default, RSC would be a constant uphill fight.