Topics
Frontend Architecture

React Server Components

How one React tree becomes two programs, what may cross the server/client boundary, why the payload is not HTML, and how streaming and server actions fit in.

Intermediate·14 min read·Updated Sep 28, 2026

React Server Components (RSC) split one component tree into two programs. Server components run only on the server (or at build time), can read a database or the file system directly, and ship no JavaScript to the browser. Client components are the React you already know: hydrated in the browser, holding state and event handlers. The server half does not produce HTML but a serialised description of the tree, the RSC payload, which client React merges into the running page. Almost every "why does this not work" moment is a question about what may cross the boundary between the two.

Context

React shipped in 2013 as a browser-only library. Server-side rendering (SSR) arrived soon after: render the tree to an HTML string on the server so the user sees content before the JavaScript loads, then hydrate that HTML in the browser by attaching the same components to it. Next.js (2016) made this the default and added per-page data loaders such as getServerSideProps. Two costs remained: every component's code still went to the browser to hydrate it, and data had to be fetched at the page level and passed down through props.

In December 2020 the React team published the Server Components RFC and demo: let some components run only on the server, so their code and their data dependencies never leave it. React 18 (2022) added the streaming and Suspense plumbing; Next.js 13 (October 2022) shipped the App Router as the first production implementation; React 19 (December 2024) made server components, server actions and the 'use client' and 'use server' directives stable. React Router and Vite added support in 2025, so the model is no longer Next.js-only.

You have met RSC as the error "You're importing a component that needs useState. Add the 'use client' directive", as an async function Page() that awaits a database call inside a component, and as a 'use client' line at the top of every file that has a button. The simplest example is a component that would have been impossible in 2020:

app/products/page.tsx (server component)
import {db} from '@/db'

export default async function ProductsPage() {
  const products = await db.product.findMany({take: 20})   // runs on the server
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name} — ${p.price}</li>)}
    </ul>
  )
}
// No useEffect, no fetch, no loading state, no JS for this component in the browser.
Server component
The default in an RSC app. Renders on the server, may be async, cannot use state, effects or browser APIs. Its code never reaches the browser.
Client component
A module that starts with 'use client'. Rendered to HTML on the server for the first paint, then hydrated and interactive in the browser.
Boundary
The point where a server component renders a client component. Props passed across it must be serialisable.
RSC payload
The serialised tree the server sends: rendered output of server components, plus references to client components and their props.
Hydration
Attaching React to server-rendered HTML so it becomes interactive. Only client components hydrate; server components have nothing to attach.
Server action / server function
A function marked 'use server' that the client can call; React turns it into a POST endpoint.

Why it matters

RSC is the default in Next.js since version 13, so most React jobs now involve it, and it changes the answers to old questions: where data fetching lives, what a bundle contains, how much a page costs on a phone. The failure modes are specific. Put 'use client' in the root layout and the whole app becomes a client bundle again. Import a database module from a client component and the bundler tries to ship your connection string. Await three things in sequence across nested server components and the page waits for a waterfall the user cannot see.

Two programs, one tree

server · runs per requestLayoutPage · async, reads dbProductListAddToCart · 'use client'server components render to a tree,client components stay as referencesRSC payloadstreamedbrowserLayout → output, 0 JSPage → output, 0 JSProductList → output, 0 JSAddToCart → hydrated, JSonly client components ship code;their state survives navigation
A product page as the server and the browser see it. Server components render once and arrive as output; only the client component ships and runs JavaScript.

The rules follow from the picture. A server component can render a client component and pass it props. A client component cannot import a server component, because importing means "bundle this into my code", and the server component's code must not be bundled. It can, however, receive a server component's output as children or any other prop, which is how a client-side provider or modal wraps server-rendered content.

AddToCart.tsx (client component)
'use client'
import {useState} from 'react'

export function AddToCart({productId}: {productId: string}) {
  const [qty, setQty] = useState(1)
  return (
    <form action={`/cart/${productId}`} method="post">
      <input type="number" value={qty}
             onChange={e => setQty(+e.target.value)} />
      <button>Add</button>
    </form>
  )
}
ProductList.tsx (server component using it)
import {db} from '@/db'
import {AddToCart} from './AddToCart'

export async function ProductList() {
  const products = await db.product.findMany()
  return products.map(p => (
    <article key={p.id}>
      <h2>{p.name}</h2>
      {/* props cross the boundary: must be serialisable */}
      <AddToCart productId={p.id} />
    </article>
  ))
}

What may cross the boundary

Props from a server component to a client component travel inside the RSC payload, so they must be serialisable by React's protocol. The list is broader than JSON and narrower than JavaScript.

KindCrosses the boundaryDoes notWhy
DataPrimitives, plain objects, arrays, Date, Map, Set, typed arraysClass instances, Symbols, objects with prototypes or methodsThe payload is data, not code; methods cannot be serialised.
ElementsJSX / ReactNode: rendered server output, including other server componentsA server component type itself, as a value to render laterOutput can be sent; a component function cannot run in the browser.
AsyncPromises (React 19; read with use())Callbacks and event handlersA promise resolves to serialisable data; a closure has server-side scope.
FunctionsServer functions ('use server')Any other functionA server function is sent as a reference and becomes a POST call, so it is the one function that can cross.

The donut: client wrapper, server filling

ThemeProvider.tsx (client)
'use client'
import {createContext, useState} from 'react'

export const Theme = createContext('light')

export function ThemeProvider({children}: {children: React.ReactNode}) {
  const [theme, setTheme] = useState('light')
  return <Theme.Provider value={theme}>{children}</Theme.Provider>
}
// children is already-rendered server output; this component never
// imports or knows about the server components inside it.
app/layout.tsx (server)
import {ThemeProvider} from './ThemeProvider'
import {Nav} from './Nav'          // server component, reads session

export default function RootLayout({children}) {
  return (
    <html><body>
      <ThemeProvider>
        <Nav />                 {/* server output passed as children */}
        {children}
      </ThemeProvider>
    </body></html>
  )
}

Why the payload is not HTML, and how it streams

If the server only sent HTML, a client-side navigation would have to replace the document and lose every client component's state: the open dropdown, the typed search, the scroll position of a list. Instead the server sends a description of the tree. React walks it and reconciles like any re-render: server-component output is swapped in as new elements, client components referenced by module ID are looked up in the bundle, and any that were already mounted with the same key keep their state.

rsc-payload (abridged; one row per line, streamed in order)
0:["$","div",null,{"className":"page","children":["$","$L1",null,{"productId":"42"}]}]
1:I["./AddToCart.tsx",["chunk-8f3a.js"],"AddToCart"]
2:["$","ul",null,{"children":[["$","li","a1",{"children":"Keyboard — $89"}]]}]
3:"$Sreact.suspense"
4:["$","$3",null,{"fallback":"Loading reviews…","children":"$L5"}]
   … later, when the reviews query finishes:
5:["$","section",null,{"children":"4.7 ★ · 212 reviews"}]
  1. 1
    The browser requests a page. The server renders every server component, awaiting their data. Client components are also rendered to HTML on the server for the first paint (that part is ordinary SSR), and the payload is embedded in the HTML so React can hydrate without a second request.
  2. 2
    Anything wrapped in <Suspense> whose data is not ready is sent as a placeholder row; the HTTP response stays open and the real rows stream in when the awaits resolve. The user sees the shell in the first hundred milliseconds and the slow parts fill in.
  3. 3
    In the browser React hydrates only the client components, looking each up by the module ID in the payload. Server-component output has no JavaScript to attach, so hydration cost scales with interactivity, not with page size.
  4. 4
    On a client-side navigation the framework fetches just the payload for the new route (no HTML), and React reconciles it into the existing tree. Layouts that did not change and client state inside them are preserved.

Mutations: server actions

Reads flow down through server components; writes flow back through server functions. A function marked 'use server' stays on the server, and the client receives only a reference. Calling it, or setting it as a form's action, makes React POST the serialised arguments to a framework endpoint, run the function, and return the result together with a fresh payload for the affected routes.

actions.ts
'use server'
import {db} from '@/db'
import {auth} from '@/lib/auth'
import {revalidatePath} from 'next/cache'

export async function addToCart(productId: string, qty: number) {
  const user = await auth()                  // it is a public endpoint:
  if (!user) throw new Error('unauthorized') // always check here
  if (!Number.isInteger(qty) || qty < 1) throw new Error('bad qty')
  await db.cartItem.upsert({/* … */})
  revalidatePath('/cart')                    // refresh server output
}
AddToCart.tsx (client)
'use client'
import {useTransition} from 'react'
import {addToCart} from './actions'

export function AddToCart({productId}: {productId: string}) {
  const [pending, start] = useTransition()
  return (
    <button
      disabled={pending}
      onClick={() => start(() => addToCart(productId, 1))}
    >
      {pending ? 'Adding…' : 'Add to cart'}
    </button>
  )
}
// Works before hydration too if used as <form action={addToCart}>

Pitfalls

  • 'use client' at the top of the tree

    Putting the directive in the root layout, or wrapping the app in a client component that imports the pages, makes every descendant part of the client bundle: you are back to shipping and hydrating everything. Keep client components as leaves, and pass server output into them as children.

  • Treating server components as "SSR that runs again on the client"

    Server components never run in the browser. They cannot hold state or respond to events, and they do not re-render when a client component's state changes; they re-render only when the server sends a new payload (navigation, revalidation, a server action). Anything interactive has to be a client component, and the server part around it stays static until refetched.

  • Sequential awaits across nested components

    A page that awaits the user, then renders a component that awaits their orders, then one that awaits each order's items, is a request waterfall hidden behind clean-looking code, and it can turn into the N+1 pattern. Start independent fetches together, hoist shared ones, and put slow ones behind Suspense so they stream.

  • Passing a function or class instance as a prop

    <Chart onSelect={handleSelect} /> from a server component fails at build or runtime because a closure cannot be serialised. Either move the handler into the client component, or make it a server function if it really should run on the server.

  • Server actions without authorisation checks

    Every server function is a POST endpoint anyone can call with any arguments, regardless of which button it was attached to. Authenticate, authorise and validate inside the function as you would in an API route; the fact that the UI only shows the button to admins protects nothing.

Interview questions

Q1What is the difference between server-side rendering and React Server Components?

SSR renders the whole tree to HTML on the server and then ships the whole tree's JavaScript to hydrate it, so it saves time to first paint but not bundle size. Server components never ship: they run only on the server, their output is sent as a serialised tree, and only the client components inside it are hydrated. The two compose: in an RSC app, client components are still server-rendered to HTML for the first paint.

Q2When do you need 'use client'?

When a component uses state, effects, refs, browser APIs, event handlers or context consumers, since none of those exist on the server. I put it on the smallest component that needs it, not the page, because everything it imports joins the client bundle. A component that only renders props and children does not need it even if it is rendered inside a client component, as long as it is passed in rather than imported.

Q3How does a client component get data that lives in the database?

A server component fetches it and passes it down as props, which must be serialisable, or passes an un-awaited promise that the client component reads with use() inside Suspense so it streams. For data the client needs to refetch on its own, it calls a server function or an ordinary API route. What it should not do is import the database module; that either fails to build or leaks server code into the bundle.

Q4Can a client component render a server component?

Not by importing it, because importing bundles it. It can render server component output that a server component passed to it as children or another prop. That is how providers, layouts and modals written as client components wrap server-rendered content: the server renders the filling and the client component just places it.

Q5Why is the RSC payload a custom format and not HTML?

Because client navigation has to merge new server output into a running React tree without destroying client state. HTML can only replace the document; the payload describes elements and references to client components, so React can reconcile it like a re-render, keeping mounted client components and their state where the keys match. It also streams row by row, so Suspense boundaries can fill in out of order.

Q6What happens, step by step, when the user clicks a link to another route?

The router intercepts the click and requests only the RSC payload for the new route, not HTML. The server renders the server components for that route, streaming Suspense boundaries as they resolve. React reconciles the payload into the current tree: shared layouts and their client state are kept, changed segments are swapped, and any client components new to the page are loaded by module ID and hydrated. No full page load, no lost input state in the persistent layout.

Q7Walk me through splitting a product page into server and client components.

Layout, page, product details, description and reviews list are server components: they read the database directly and ship no JavaScript. The add-to-cart control, image gallery and review form are client components because they hold state and handle events; they receive product IDs and data as props. Reviews go behind Suspense so the fast parts stream first. Add to cart calls a server function that checks the session, writes, and revalidates the cart route.

Key takeaways
  • Server components run only on the server, may be async, read data directly and ship zero JavaScript. Client components ('use client') hold state and events and are hydrated.
  • The boundary rule: server can render client and pass serialisable props; client cannot import server but can receive its output as children.
  • The server sends an RSC payload, not HTML, so navigation can reconcile new server output into the live tree and keep client state. Suspense boundaries stream in as rows.
  • Keep client components as leaves. The directive marks an entry point, and everything it imports joins the bundle; guard server modules with server-only.
  • Fetch in parallel and stream slow parts; nested sequential awaits are waterfalls and can become N+1.
  • Server functions are public POST endpoints: authenticate and validate inside them, every time.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free