← Field Notes

React Server Components — Part II of III

RSC Is Not SSR. I Know, I'm Sorry.

Let me save you a week of confusion: RSC and SSR are not the same thing.

I also thought they were kind of the same thing for an embarrassingly long time. RSC sounds like something server-related, SSR is Server-Side Rendering which is definitely server-related, and Next.js does both at the same time — so it all gets blurry real fast.

The distinction that finally made it click for me:

SSR sends you HTML.
RSC sends you React.
These are very different things.

What SSR actually does

When you use traditional SSR — like Next.js Pages Router withgetServerSideProps, or any framework that renders on the server — here's what happens:

  1. Your server runs React, rendering your component tree into HTML strings.
  2. That HTML gets sent to the browser. The user sees content immediately, which is the whole point. Fast first paint.
  3. The browser also downloads your JavaScript bundle — which contains all your component code.
  4. React "hydrates" — it re-runs your components on the client to attach event handlers and make the page interactive.

Notice step 3. Every component you wrote — UserProfile, OrderList, StatsCard, all of them — their JavaScript code ends up in the browser bundle. Even the ones that have no interactivity whatsoever. You pay that cost whether you like it or not.

Also notice: the data fetching in traditional SSR is decoupled from the components. You fetch everything you need in getServerSideProps, then pass it down as props. The components themselves still don't know how to fetch their own data — they just receive whatever they're given.

What RSC actually does

RSC takes a completely different approach. Instead of rendering to HTML, server components render to a special format — sometimes called the React Flight protocol or the RSC payload. It's not HTML. It's not JSON. It's a serialized React tree.

Your browser receives this payload and React reconstructs the component tree from it — without having to re-run any of the server component code, because the server component code was never sent to the browser in the first place.

The component tree explorer below shows what I mean. Click on any component to see what runs where and why.

Component Tree Explorer
ServerClient

Click any component to see what it can and can't do in that environment.

Server Component<BlogPage/>

Reads the article from the database at request time. Nothing interactive happens at this level — it just orchestrates the layout.

✓ Can do

  • async/await at component level
  • Direct database access
  • Read server env vars
  • Zero JS sent to client

✗ Cannot do

  • useState / useReducer
  • onClick / onChange
  • useEffect
  • browser APIs

Serializes to client

article: { id, title, body, likes: 128 }

See the pattern? The server components form most of the tree. They handle data fetching, rendering, and layout. The client components — LikeButton and CommentForm — are just the small interactive islands that genuinely need browser APIs.

The "use client" directive is not what you think it is

Most people read "use client" and think it's a label that marks a component as "this runs on the client." It's not. It's a module system boundary.

What it actually says is: everything in this file, and everything this file imports, is the client boundary. React will bundle all of it and ship it to the browser. Once you cross the boundary by importing from a "use client" file, you're in client territory.

what "use client" actually means
// LikeButton.tsx
'use client';  // ← This is a module boundary, not a component property

// Everything below this line runs on the client.
// Everything this file imports also runs on the client.

import { useState } from 'react';
import { motion } from 'framer-motion'; // ← also gets bundled

export function LikeButton({ articleId, initialCount }) {
  const [count, setCount] = useState(initialCount);
  // ...
}

The implication: be careful what you import inside "use client" files. If you accidentally import a large library inside a client component, it ends up in the browser bundle. The goal is to keep client components small and focused.

Composition across the boundary

There's a pattern that confuses a lot of people: you cannot import a server component inside a client component. But you can pass server components as props to client components — specifically as children.

passing server components as children
// ✗ This doesn't work — you can't import a server
// component inside a client component
'use client';
import { ServerSideWidget } from './ServerSideWidget'; // ✗

// ✓ This works — pass server components as children
// from a parent server component

// ParentServer.tsx (server component)
import { ClientShell } from './ClientShell';
import { ServerSideWidget } from './ServerSideWidget';

export function ParentServer() {
  return (
    <ClientShell>
      <ServerSideWidget /> {/* ← server component passed as children */}
    </ClientShell>
  );
}

// ClientShell.tsx
'use client';
export function ClientShell({ children }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children} {/* ← server component renders here */}
    </div>
  );
}

The reason this works: the server component is rendered on the server before it's passed as children. By the time the client component receives it, it's already a serialized React tree — not executable code. The client component never needs to import or run the server component.

What React actually sends to the browser

This is the part that almost no one talks about, and I think it's the most useful thing to understand. The RSC payload format is genuinely different from anything React did before. Let's look at it.

This is what you write. A server component is just an async function that can talk directly to your database. No useEffect. No API route. No credentials leaking to the client.

1// Server Component — runs only on the server
2export async function ArticleCard({ id }: { id: number }) {
3 // This DB call never reaches the browser
4 const article = await db.articles.findById(id);
5
6 return (
7 <article>
8 <h1>{article.title}</h1>
9 <p>{article.body}</p>
10 <LikeButton
11 articleId={id}
12 initialCount={article.likes}
13 />
14 </article>
15 );
16}

Look at the RSC Wire Format tab. The key thing to notice: the server component code (ArticleCard) is gone. What's left is just the rendered output — the serialized React tree. The ArticleCard function itself never reaches the browser.

LikeButton is there, but only as a reference ($L1) pointing to a module (M1). React knows to load that JavaScript chunk and insert the client component at the right place in the tree.

RSC + Suspense = streaming

One more thing worth knowing: RSC and Suspense work together to enable streaming. Instead of waiting for every server component to finish fetching before sending anything, React can start streaming the tree to the browser as each component resolves.

server component with streaming via Suspense
// page.tsx (server component)
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header /> {/* ← renders immediately, no data needed */}
      <Suspense fallback={<ArticleSkeleton />}>
        <ArticleContent /> {/* ← streams in when ready */}
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection /> {/* ← streams in independently */}
      </Suspense>
    </div>
  );
}

The header renders immediately. The article and comments stream in as their data becomes available — independently of each other. No artificial loading hierarchy, no "wait for everything before showing anything."

What can and can't a server component do?

Quick summary, because people ask:

Server Components can
  • Use async/await at the component level
  • Read from databases, filesystems, or any server-side resource directly
  • Access environment variables (including secret ones)
  • Import server-only packages without bloating the client bundle
  • Reduce the amount of JavaScript shipped to the browser to zero, for those components
Server Components cannot
  • Use useState, useReducer, or any state hook
  • Use useEffect or any lifecycle hook
  • Use event handlers (onClick, onChange, etc.)
  • Use browser-only APIs (window, document, localStorage)
  • Use Context as a consumer (but you can provide Context from server components)

The mental model shift

Here's the reframe that helped me internalize RSC: instead of thinking "which components run on the server?" — think "which components genuinely need to run in a browser?"

In most apps, the answer to the second question is: not many. State management, event handlers, browser animations, form inputs — those components need to be client components. But a lot of what we write is just data fetching + rendering. That doesn't need a browser.

By default in Next.js App Router, all components are server components. You opt into client behavior with "use client". That inversion from what we were used to is what confuses people, but once it clicks, it makes a lot of sense.

One thing I want to be honest about: this model adds complexity. You now have to think about component boundaries you never had to think about before. You'll hit edge cases with third-party libraries that assume a browser environment. It's a genuine trade-off. Part III is where we get into when that trade-off is worth it.

Divyanshu Kumar