PrimeStack.
Guides·Sep 30, 2025

Understanding React Server Components (RSC) Deeply

RSC is more than just a performance optimization. It's a paradigm shift in how we build hybrid applications.


React Server Components (RSC) represent the most significant shift in React's programming model since hooks. They're frequently described as a performance feature — smaller bundles, faster data fetching — but that framing undersells what RSC actually is. RSC changes where computation happens, what data flows where, and how you reason about the boundary between server and client in a React application.

If you've been using RSC through the Next.js App Router without fully understanding the mental model, or if you've been avoiding it because the documentation felt abstract, this guide will give you the ground-level understanding you need.

Table of Contents


The Problem RSC Solves

To understand why RSC exists, you need to understand two specific problems with the pre-RSC React architecture.

The Network Waterfall Problem

In a traditional React application, data fetching is tied to component rendering. A parent component fetches data, renders, and then child components mount and trigger their own fetches. This creates sequential round trips — a waterfall:

Browser → Server: fetch page HTML
Browser ← Server: shell HTML
Browser → Server: fetch JS bundle
Browser ← Server: bundle
Browser renders, mounts components
Browser → API: fetch user data
Browser ← API: user data
Browser re-renders, child components mount
Browser → API: fetch posts for user
Browser ← API: posts data
Browser re-renders final state

Each arrow represents a network round trip with its associated latency. On a mobile connection with 200ms RTT, this sequence accumulates over a second of wait time before the user sees meaningful content — and this is a simple two-level data dependency.

Client Bundle Size

Before RSC, every component in a React application shipped to the browser, even components that only existed to transform data from an API into JSX. A component that fetches a markdown post from a CMS and renders it as HTML would ship the markdown parser, the syntax highlighter, and all their dependencies to the browser — even though the parsing happens at render time and the output is static HTML.

RSC solves both problems by moving computation to the server. A server component that imports a 100KB markdown parser doesn't add that library to the client bundle. A server component that fetches data does so on the server, eliminating the client-to-API round trip entirely.


How RSC Works Under the Hood

RSC is not server-side rendering (SSR). SSR renders React components to HTML strings on the server and sends that HTML to the browser; React then hydrates those strings by attaching event listeners. Both server and client execute the same React component code.

RSC introduces a different model. Server components execute on the server and are never hydrated. They produce a serializable representation of their output — not HTML, but a format called the RSC payload.

The RSC Payload Format

The RSC payload is a JSON-like serialization format that describes the component tree's output. It's transmitted as a stream, meaning the browser can start processing it before the full response is complete.

The payload encodes:

  • Rendered output from server components (as React element descriptors, not HTML)
  • References to client components by their module path
  • Props passed from server to client components (these props must be serializable)

When the browser receives the RSC payload, React reconciles it against the existing component tree. Client components referenced in the payload are fetched as JavaScript modules and hydrated normally. Server component output is inserted directly into the tree without hydration — there's nothing to hydrate because server components have no client-side state or event listeners.

The Two-Phase Render

When a request hits a Next.js App Router page:

  1. Server render phase: React renders the component tree starting from the root layout. Server components execute: they run async functions, access databases, read file system, import heavy libraries. They produce output that either becomes HTML (for static content) or RSC payload (for dynamic boundaries).

  2. Client hydration phase: The browser receives the HTML (for initial load) and the RSC payload (streamed alongside or separately). React hydrates client components — the portions of the tree marked with "use client" — attaching event handlers and making them interactive. Server component output is not touched during hydration.

Streaming

RSC's payload format is designed for streaming. React uses <Suspense> boundaries to control which parts of the tree stream immediately and which parts wait for data. The browser renders what's available and inserts deferred content as it arrives, without a full page re-render.

This is why the App Router uses loading.tsx files — they define the fallback content that renders while a Suspense boundary is waiting for streamed content from the server.


The Mental Model Shift

The fundamental shift is this: in the pre-RSC model, the server renders HTML from a complete component tree. In the RSC model, the component tree itself is split across server and client, and they collaborate on producing the final output.

Server components are React components that run on the server, have access to server-side resources (databases, file system, environment variables), and produce output that flows to the client as data — not JavaScript that executes on the client.

Client components are traditional React components. They run on the client (after hydration), can use hooks, manage state, attach event listeners, and access browser APIs. They also run on the server during SSR to produce initial HTML — this is the source of much confusion. "Client component" doesn't mean "only runs on client." It means "this component will hydrate and run on the client."

The distinction that matters: server components never run on the client. They render once on the server and their output is static from the client's perspective. Client components run on the server for SSR and then again on the client for hydration and subsequent interactions.


What You Can and Cannot Do in Server Components

What Works in Server Components

Async/await directly in the component. This is the most important capability. Server components can be async functions:

async function UserProfile({ userId }: { userId: string }) {
  const user = await db.users.findById(userId);
  const posts = await db.posts.findByUserId(userId);

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

No useEffect, no loading state, no empty render followed by a re-render with data. The component doesn't render until its data is ready.

Direct database access. Server components can import your database client, ORM, or any server-side library. These imports don't affect the client bundle.

Environment variables and secrets. Server components have access to process.env and can safely use API keys and database credentials that must never reach the browser.

File system access. Reading local files (blog posts as MDX files, configuration files, etc.) is possible directly in server components.

What Does Not Work in Server Components

React hooks. Hooks like useState, useEffect, useContext, useRef, and useReducer are client-only. Server components have no lifecycle, no state, and no side effects — they render once and produce output. This is by design: server components are pure functions from props + server data to React output.

Browser APIs. window, document, localStorage, navigator, and anything in the browser runtime don't exist on the server. Accessing them in a server component will throw at runtime.

Event handlers. Props like onClick, onChange, and onSubmit cannot be passed to or used within server components. Event handling is fundamentally a client-side concern.

Context providers that wrap the tree. Context itself can be read in server components (via a different mechanism), but the createContext / useContext pattern is client-only. For global data that server components need, fetch it and pass it as props, or fetch it separately in each component that needs it (Next.js deduplicate fetch calls within a request).


The Component Tree Boundary Model

The server/client split in RSC is defined by boundaries, not by individual components. A boundary is created whenever a client component imports another component — everything below that import is in the client bundle.

RootLayout (server)
├── Nav (server)
│   └── SearchBar (client) ← "use client" boundary
│       └── SearchResults (client) ← also client, inside boundary
├── Page (server)
│   ├── ProductInfo (server)
│   └── AddToCartButton (client) ← "use client" boundary
│       └── QuantitySelector (client) ← also client, inside boundary
└── Footer (server)

The key rules:

  1. Server components can import and render client components.
  2. Client components cannot import server components. (They can receive them as props — more on this below.)
  3. The "use client" directive marks a module boundary, not individual components. Everything imported by a client component module is treated as client code.

Passing Server Data to Client Components

Server components can pass data to client components through props:

// Server component
async function ProductPage({ id }: { id: string }) {
  const product = await getProduct(id);

  return (
    <div>
      <ProductDetails product={product} />
      {/* AddToCartButton is a client component */}
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
}

This is the correct pattern: fetch data in the server component, pass the necessary subset to the client component as props. The client component receives serializable values (strings, numbers, plain objects) — not database clients or unserializable server-side resources.

You can also pass server components as children to client components, which allows server content to render inside client component wrappers:

// In a server component:
<Modal>
  <UserProfile userId={userId} /> {/* server component as child */}
</Modal>

// Modal is a client component that renders `children` (its prop)
// UserProfile still executes as a server component

This pattern "slots" server-rendered content into client component shells without pulling the server component into the client bundle.


RSC with Next.js App Router

The App Router is the primary production implementation of RSC as of 2025. Every component in the app/ directory is a server component by default. Client components require an explicit "use client" directive at the top of the file.

Layouts and Pages

Layouts (layout.tsx) are server components that wrap pages and persist across navigations without re-fetching their data. A layout that fetches navigation data fetches it once, and subsequent page navigations within that layout don't re-run the layout's server render (unless the layout itself is invalidated).

Pages (page.tsx) receive params and searchParams as props. They're async functions:

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);
  return <ProductDetails product={product} />;
}

Loading and Error States

loading.tsx defines the Suspense fallback for a route segment. It renders immediately while the page's async server component is awaiting data. This is how App Router achieves streaming — the shell renders (with the loading state) while data fetches complete on the server, and the page content streams in when ready.

error.tsx defines the error boundary for a segment. It must be a client component (because React error boundaries require componentDidCatch or useErrorBoundary, which are client-side):

"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Data Fetching Patterns

Fetch in Component

The recommended pattern in RSC is to fetch data as close to where it's used as possible. A UserAvatar component that needs the user's avatar URL should fetch it itself, rather than receiving it through a prop chain originating from a page-level fetch:

async function UserAvatar({ userId }: { userId: string }) {
  const { avatarUrl } = await db.users.findById(userId, { select: ["avatarUrl"] });
  return <img src={avatarUrl} alt="User avatar" />;
}

Next.js deduplicates fetch calls within a single request using a request-scoped cache. If multiple components in the same render tree call fetch("/api/users/123"), only one HTTP request is made. This makes it safe to fetch data at the component level without worrying about duplicate requests.

Parallel Fetching

When data dependencies are independent, fetch them in parallel using Promise.all:

async function UserDashboard({ userId }: { userId: string }) {
  const [user, posts, notifications] = await Promise.all([
    getUser(userId),
    getPosts(userId),
    getNotifications(userId),
  ]);

  return (
    <div>
      <UserHeader user={user} />
      <PostList posts={posts} />
      <NotificationBadge count={notifications.unread} />
    </div>
  );
}

Sequential When Necessary

Sometimes later fetches depend on data from earlier ones. Sequential fetching is fine in server components — there's no re-render cost:

async function PostWithAuthor({ postId }: { postId: string }) {
  const post = await getPost(postId);
  const author = await getUser(post.authorId); // depends on post

  return <PostView post={post} author={author} />;
}

If the sequential dependency can be eliminated by restructuring the query, do so. But sequential fetches on the server are dramatically cheaper than the equivalent client-side waterfall — the server-to-database latency is typically sub-millisecond, compared to hundreds of milliseconds for a browser-to-API round trip.


When to Use use client

The directive "use client" should be applied at the lowest level in the component tree that actually requires client-side capabilities. This minimizes the amount of JavaScript in the client bundle.

Use "use client" when the component:

  • Uses useState, useReducer, or any other React hook
  • Attaches event handlers (onClick, onChange, onSubmit)
  • Uses browser APIs (window, localStorage, navigator.geolocation)
  • Uses real-time subscriptions or WebSocket connections
  • Needs client-side animations or transitions that require access to DOM measurements

Do not use "use client" for:

  • Components that only render data passed as props with no interactivity
  • Components that fetch their own data (use async server components instead)
  • Components that exist purely for layout or visual structure

A common structural pattern: server components handle data fetching and pass data to small, focused client components that handle interactivity. A ProductPage server component fetches all product data; an AddToCartButton client component receives only the productId and price it needs for the cart interaction.


Common Mistakes and Misconceptions

Assuming client components don't run on the server. They do — during SSR. The "use client" directive means "this component hydrates and runs on the client." It does not mean "skip server rendering." Code inside a client component that assumes a browser environment (accessing window directly without a guard) will throw during SSR.

Adding "use client" to every component. Some developers, frustrated with RSC constraints, mark everything as a client component to get back to familiar patterns. This defeats the purpose of RSC — you now have a traditional React app with extra framework overhead and no bundle optimization.

Fetching in client components when a server component would work. A useEffect that fetches data on mount and sets it in state is a pattern that RSC makes obsolete in many cases. If the data doesn't change based on user interaction, fetching it in a server component is more efficient: no client bundle for the fetching logic, no loading state, no empty-then-populated render.

Trying to pass non-serializable values from server to client components. Functions, class instances, and database connections cannot be serialized across the server/client boundary. Attempting to pass them as props causes a runtime error. The solution: move the logic that uses these values to the server component, and pass only the resulting data to the client component.

Confusing RSC with SSR. SSR is about rendering to HTML on the server to improve initial page load. RSC is about running components on the server to reduce bundle size and eliminate client-to-API round trips. They are complementary, and Next.js uses both simultaneously — but they address different problems.


Future Implications

RSC changes the architectural default for React applications. The previous default was: fetch in useEffect or with a data fetching library (React Query, SWR), manage loading and error states in client-side hooks, and keep all component logic in the browser. RSC shifts the default toward: fetch data on the server, render on the server, and push to the client only what requires interactivity.

The Server Actions feature (stable in Next.js 14 and beyond) extends this model to mutations. A form can submit to a server action — a function that runs on the server — without writing an API route. The entire request/response cycle for a form submission is colocated with the form component.

The longer-term implication is a convergence between React applications and the server-rendered web paradigm that predated single-page applications. The React team's stated goal is a programming model where you don't need to think about "is this client or server" for most of your application — the framework should make the right call. RSC is the foundation of that vision.

For developers building production applications today, the practical takeaway is this: the App Router's default (everything is a server component unless you opt into "use client") is correct for the majority of components. Start with server components. Add "use client" when you need interactivity. Fetch data in the component that uses it. The architecture that results will be faster, have a smaller bundle, and be easier to reason about than the equivalent SPA data fetching patterns it replaces.