Skip to content
C Codeloom
React

React Server Components: How They Actually Work

A clear, practical explanation of React Server Components: the runtime model, the boundary between server and client, data fetching, and the tradeoffs.

·6 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • What React Server Components are and what they are not
  • How the server and client component boundary works
  • How data fetching changes with server components
  • How streaming, Suspense, and serialization fit together
  • When server components help and when they get in the way

Prerequisites

  • Comfort with React: see [What is React?](/blog/what-is-react)
  • Familiarity with hooks: see [React Hooks: useState and useEffect](/blog/react-hooks-usestate-useeffect)
  • Some Next.js context helps: see [What is Next.js?](/blog/what-is-nextjs)

React Server Components, often shortened to RSC, are React components that run on the server and never ship their code to the browser. They replace a category of work that used to require either a separate backend route or aggressive client fetching. They also introduce a new mental model that takes a minute to internalize.

The runtime model

A traditional React app ships a JavaScript bundle. The browser downloads it, runs it, fetches data, and renders. Server Components add a second runtime: a renderer that runs on the server, produces a special serialized format, and streams it to the client. The client uses that stream to build the UI without ever loading the server components themselves as JS.

Three kinds of components now coexist:

  • Server Components: run only on the server. Can read files, hit databases, use secrets. No state, no effects, no event handlers.
  • Client Components: run on both server (during SSR) and client. Have state, effects, and event handlers. Marked with "use client".
  • Shared Components: pure presentational pieces that work in either context.

The key constraint: a server component can import and render a client component, but a client component cannot import a server component. Data and rendered children can be passed across the boundary as props.

A first example

// app/posts/page.tsx  (Server Component by default)
import { db } from "@/lib/db";
import LikeButton from "./like-button";

export default async function PostsPage() {
  const posts = await db.posts.findMany();
  return (
    <ul>
      {posts.map(p => (
        <li key={p.id}>
          <h2>{p.title}</h2>
          <p>{p.body}</p>
          <LikeButton postId={p.id} initial={p.likes} />
        </li>
      ))}
    </ul>
  );
}
// app/posts/like-button.tsx
"use client";
import { useState } from "react";

export default function LikeButton({ postId, initial }: { postId: string; initial: number }) {
  const [likes, setLikes] = useState(initial);
  return (
    <button onClick={() => setLikes(n => n + 1)}>
      {likes} likes
    </button>
  );
}

PostsPage queries the database directly. None of its code ships to the browser. LikeButton ships, because it needs state and a click handler. The serialized RSC payload glues them together.

The boundary, in detail

"use client" at the top of a file marks every export as a client component. Anything that file imports is bundled for the browser. Anything it does not import stays server-only.

Server components can pass children down to client components. That is how you keep client surface small while still composing freely:

<ClientCard>
  <ServerStats /> {/* rendered on server, embedded as a slot */}
</ClientCard>

ServerStats is rendered to RSC payload on the server. ClientCard receives it as a children prop. The browser never sees the server stats code.

Props passed across the boundary must be serializable. Primitives, plain objects, arrays, Dates, Maps, Sets, and rendered JSX are fine. Functions, class instances, and promises generally are not. If you need behavior on the client, build it into the client component.

Data fetching

In a classic React app, you fetch in useEffect or with a data library. With RSC, you await directly in a server component. There is no client round trip, no waterfalls of useEffect, no JSON sitting in component state.

export default async function UserPage({ params }: { params: { id: string } }) {
  const [user, orders] = await Promise.all([
    db.users.findById(params.id),
    db.orders.recentFor(params.id)
  ]);
  return <UserView user={user} orders={orders} />;
}

Two requests, in parallel, with no client work. The browser receives rendered HTML and the RSC stream. The user sees the page faster, and your bundle stays small because the data-loading code is server-only.

Caching is a separate concern that the framework manages. In Next.js the App Router exposes cache, revalidate, and tags. In other RSC-aware setups, you bring your own. The point is that data fetching now belongs next to the UI that uses it.

Streaming and Suspense

RSC works hand in hand with Suspense. Wrap slow parts in a boundary and the server streams the shell first, then patches in the slow regions as they finish.

import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <SlowFeed />
      </Suspense>
    </>
  );
}

The user sees the header immediately. The feed appears when it is ready. The browser does no extra work to coordinate this. The HTML and RSC stream do it.

State, effects, and event handlers

Server components have none of these. If you need them, you have crossed the boundary and you need a client component. This sounds restrictive, but in practice the natural split is:

  • Layouts, pages, and data-heavy regions: server components.
  • Interactive widgets, forms, anything with input: client components.

For state that needs to round-trip to the server, Server Actions let you submit from the client and run code on the server without writing a separate API. This pairs naturally with React Hooks for the client side and replaces a lot of boilerplate.

What you give up

Server components are not free.

  • Files cannot mix server and client code. You will split files more than before.
  • Some libraries assume they run in the browser. If they touch window at import time, they will not work in a server component.
  • Debugging crosses two runtimes. Stack traces span the network.
  • Hosting now requires a Node-like environment, not a static CDN.

If your app is a small, highly interactive client, the overhead may not be worth it. RSC pays off when there is meaningful server-rendered content and a strong incentive to ship less JavaScript.

Mental model that helps

Think of server components as a template language with the full power of React, that runs once per request, where data fetching is just an await. Think of client components as the interactive islands inside that template. The boundary moves toward the leaves of your tree, and the leaves are small.

That is also why frameworks like Next.js put RSC at the center of the App Router. They give you the routing, caching, and streaming plumbing that RSC needs to feel cohesive.

Wrap up

React Server Components change where React work happens. The server renders most of the tree, the client hydrates only the interactive parts, and data fetching lives next to the UI that uses it. You write less glue code, ship less JavaScript, and get streaming for free. The learning curve is the new boundary between server and client. Once that clicks, the rest is just React you already know.