React Server Components (RSC) shipped as stable in React 19 and became the default in Next.js 13+. Millions of applications run on them. Most developers who use them do not fully understand the rendering model.

This is not an abstract concern. Not understanding RSC leads to performance problems, bugs with client-side state, and incorrect mental models that make debugging difficult.

The Old Model First

To understand RSC, you need to understand what it replaced and why.

Traditional SSR (Server-Side Rendering): The server renders the entire React component tree to HTML. The browser receives HTML and renders it immediately (fast First Contentful Paint). Then the browser downloads JavaScript, React runs on the client, and “hydration” connects the React component tree to the existing HTML. After hydration, the app is interactive.

The problem: hydration is expensive. React has to traverse the entire component tree, reconstruct all component state, and attach event listeners. For large apps, this could take seconds on slower devices, during which the page looked interactive but was not.

The waterfall problem: Even with SSR, if a component needed data before rendering, you had to choose between:

  1. Fetch all data server-side before any HTML is sent (slow time-to-first-byte)
  2. Render shell and fetch data client-side (waterfall: HTML, then JS, then data)

Neither was ideal.

What Server Components Are

Server Components run exclusively on the server. They are never hydrated on the client. They never become interactive. They render once and their output is sent to the client.

The key properties:

No JavaScript bundle cost. A Server Component’s code is never sent to the browser. If a Server Component imports a 500 KB markdown parsing library, that library never appears in your client bundle.

Direct server access. Server Components can directly query databases, read files, and call server-side services without going through an API layer.

// This runs ONLY on the server - never in the browser
async function UserProfile({ userId }) {
    // Direct database query - no API call needed
    const user = await db.users.findUnique({ where: { id: userId } });
    const posts = await db.posts.findMany({ where: { authorId: userId } });

    return (
        <div>
            <h1>{user.name}</h1>
            <PostList posts={posts} />
        </div>
    );
}

Async by default. Server Components are async React components. await works directly in the component body. This is only possible because they run on the server where async operations are expected.

Client Components Still Exist

Server Components handle rendering. Client Components handle interactivity.

'use client' // This directive marks a Client Component

function LikeButton({ postId }) {
    const [liked, setLiked] = useState(false);

    return (
        <button onClick={() => setLiked(!liked)}>
            {liked ? 'Liked' : 'Like'}
        </button>
    );
}

Client Components are like traditional React components. They can use hooks, handle events, and manage state. They are hydrated in the browser. They do have a JavaScript bundle cost.

The key rule: Client Components cannot import Server Components. But Server Components can import and render Client Components.

The RSC Wire Format

This is where it gets technically interesting. RSC does not send HTML to the client. It sends a special data format: a serialized description of the component tree.

When a Server Component renders, the output is serialized to a streaming JSON-like format:

1:{"children":["$","div",null,{"children":[["$","h1",null,{"children":"Alice"}],
["$","@2",null,{}]]}]}
2:I{"id":"./PostList.js","name":"default","async":false}

The server streams this incrementally as components resolve. The client reconstructs the React tree from this wire format.

Client Component slots are included as references ($@2 above), not rendered output. The client bundle handles rendering them.

Streaming Rendering

RSC enables Suspense-based streaming. Instead of waiting for all data to be ready before sending anything, the server streams parts of the page as they become ready.

// The page starts streaming immediately
// The Suspense boundary shows a skeleton until UserProfile is ready
export default function Page() {
    return (
        <div>
            <h1>Dashboard</h1>
            <Suspense fallback={<ProfileSkeleton />}>
                <UserProfile userId={userId} />
            </Suspense>
            <Suspense fallback={<FeedSkeleton />}>
                <ActivityFeed />
            </Suspense>
        </div>
    );
}

The browser receives the outer shell immediately and renders it. The UserProfile and ActivityFeed data fetch in parallel on the server. As each resolves, the server streams the HTML for that section. The browser inserts it into the page without a full re-render.

This fundamentally changes the time-to-interactive story: the page feels progressively loaded rather than all-or-nothing.

The Caching Model

Server Components can have their results cached at multiple levels:

// React's built-in cache for deduplication within a request
import { cache } from 'react';

const getUser = cache(async (id) => {
    return db.users.findUnique({ where: { id } });
});

// In Next.js, fetch is extended with cache control
async function getData() {
    const data = await fetch('/api/data', {
        next: { revalidate: 60 } // revalidate every 60 seconds
    });
    return data.json();
}

The cache() wrapper deduplicates requests within a single render pass. If UserProfile and ActivityFeed both call getUser(userId), the database is only queried once.

What Goes Wrong in Practice

Over-clientizing. Adding 'use client' to components that do not need it defeats the bundle size benefit. Every component that does not need hooks, event handlers, or browser APIs should be a Server Component.

Prop serialization errors. Props passed from Server Components to Client Components must be serializable. Functions, class instances, and circular references cannot cross the server/client boundary. This surprises developers who try to pass callbacks from Server to Client Components.

Misunderstanding “client” means “interactive”. Server Components are not just pre-rendered. They genuinely never run in the browser. Accessing window, document, or localStorage in a Server Component throws an error.

Breaking the mental model with useEffect. If you find yourself reaching for useEffect to fetch data in a Server Component, you are writing a Client Component that should be a Server Component. Server Components fetch data directly.

Bottom Line

React Server Components work by running components on the server, sending a wire format to the client rather than HTML, and streaming component output as data becomes available. Client Components handle interactivity and are hydrated; Server Components never touch the browser. The practical benefits - no bundle cost for server-side code, direct server access, streaming rendering via Suspense - are real and significant. The main source of confusion is the mental model shift: RSC is not “SSR but better.” It is a fundamentally different rendering architecture where server and client concerns are explicitly separated.