Skip to content

React9 min read

React Server Components Explained: A Clear Mental Model

React Server Components explained: how server and client components differ, what 'use client' really does, serialization rules and mistakes to avoid in Next.js.

React Server Components (RSC) are components that run only on the server, either at build time or per request, and send their rendered result to the browser instead of their JavaScript. Client Components are the React you already know: they are pre-rendered to HTML too, but their code ships to the browser so they can use state, effects and event handlers. The 'use client' directive marks the point in your import graph where server code ends and client code begins, and anything crossing that line as a prop must be serializable.

That is the whole model in three sentences. The rest of this post unpacks it with Next.js App Router examples (Next.js 16.4, React 19.3), because that is where most people meet Server Components today.

Key takeaways

  • In an RSC app, every component is a Server Component unless its module (or a module that imports it) is marked 'use client'.
  • 'use client' works on the module dependency tree, not the render tree. Everything a client module imports becomes client code.
  • Client Components still render on the server for the first HTML. "Client" means "also runs in the browser".
  • Props from Server to Client Components must be serializable: no plain functions, no class instances.
  • Pass Server Components into Client Components as children or other props instead of importing them.
  • 'use server' is for Server Functions, not Server Components.

A mental model for React Server Components

Think of your app as two programs that share one component tree.

The server program runs your Server Components. They can be async, read a database, read files, and use secrets. Their output is not HTML directly; it is a serialized description of the UI called the RSC payload. React on the server turns that payload, plus the client components, into HTML for the first load.

The client program is the bundle the browser downloads. It contains only Client Components and their dependencies. When it starts, React hydrates the HTML, attaching event handlers to the client parts. Server Components are already "done" and never hydrate.

So ask of every component: does it need to run in the browser? If it needs state, effects, event handlers or browser APIs, yes. Everything else can stay on the server and ship zero JavaScript.

Server Components vs Client Components

Server Component Client Component
Default in Next.js App Router Yes No, needs 'use client'
Can be async and await data Yes No (use use() with a passed Promise instead)
Access DB, file system, secrets Yes No
useState, useEffect, useReducer No Yes
Event handlers (onClick) No Yes
Browser APIs (window, localStorage) No Yes (in effects or handlers)
JavaScript sent to browser None for the component itself Component code + its imports
Rendered to HTML on first load Yes Yes

A Server Component in Next.js

TSX
// app/products/page.tsx  (Server Component by default)
import { db } from '@/lib/db';
import { AddToCartButton } from './add-to-cart-button';

export default async function ProductsPage() {
  const products = await db.product.findMany({ take: 20 });

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}: {p.price}
          <AddToCartButton productId={p.id} />
        </li>
      ))}
    </ul>
  );
}

The database client, the query and the mapping logic never reach the browser. Only the rendered list and the small button component do.

A Client Component

TSX
// app/products/add-to-cart-button.tsx
'use client';

import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => setAdded(true)} disabled={added}>
      {added ? 'Added' : 'Add to cart'}
    </button>
  );
}

productId is a string, so it crosses the boundary without trouble.

What 'use client' actually does

The React docs on 'use client' describe it as introducing "a server-client boundary in the module dependency tree". Three consequences follow, and each one surprises people.

1. Everything a client module imports is client code

TSX
// components/fancy-text.tsx  (no directive)
export function FancyText({ text }: { text: string }) {
  return <span className="fancy">{text}</span>;
}

If page.tsx (a Server Component) imports FancyText, it renders on the server. If a 'use client' file imports the same FancyText, that copy is bundled and runs on the client. Same file, different role, decided by who imports it.

This is why 'use client' on a big layout file is expensive.

2. You only need the directive at the entry point

You do not need 'use client' in every interactive file. Once a module is client code, its imports are too. Put the directive on the components that Server Components import directly, and keep those as small and as close to the leaves of the tree as you can.

3. Client still renders on the server

'use client' does not mean "skip SSR". Your Client Components produce HTML on the server and hydrate in the browser. Code that touches window at the top of the render body will still crash on the server. Read browser APIs inside useEffect or event handlers. React 19.3 also added use(browser()) from react-dom for components that should only render in the browser; it must be called from a Client Component inside a <Suspense> boundary.

Serialization rules: what can cross the boundary

Props passed from a Server Component to a Client Component get serialized into the RSC payload. According to the React docs, these are supported:

  • Primitives: string, number, bigint, boolean, undefined, null, and symbols registered with Symbol.for
  • Arrays, Map, Set, typed arrays and ArrayBuffer containing serializable values
  • Date
  • Plain objects with serializable properties
  • Server Functions (marked 'use server')
  • JSX elements (Client or Server Components)
  • Promises

And these are not supported:

  • Regular functions (like an onClick handler defined in a Server Component)
  • Classes and class instances (other than the built-ins above), and null-prototype objects
  • Unregistered symbols like Symbol('x')

The classic serialization error

TSX
// Server Component: this fails
export default function Page() {
  return <Counter onChange={(n) => console.log(n)} />;
}

The fix is either to move the handler into the Client Component, or to pass a Server Function:

TSX
// app/actions.ts
'use server';

export async function saveCount(n: number) {
  // write to DB
}
TSX
// app/counter.tsx
'use client';
import { useState } from 'react';

export function Counter({ onChange }: { onChange: (n: number) => Promise<void> }) {
  const [count, setCount] = useState(0);
  return (
    <button
      onClick={() => {
        const next = count + 1;
        setCount(next);
        void onChange(next);
      }}
    >
      Count: {count}
    </button>
  );
}
TSX
// app/page.tsx (Server Component)
import { Counter } from './counter';
import { saveCount } from './actions';

export default function Page() {
  return <Counter onChange={saveCount} />;
}

Composing server and client components

A Client Component cannot import a Server Component, because the import would pull it into the client bundle. But it can render one that it receives as a prop. This "children as a slot" pattern is the key to keeping most of your tree on the server.

TSX
// app/components/collapsible.tsx
'use client';

import { useState, type ReactNode } from 'react';

export function Collapsible({ title, children }: { title: string; children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen((o) => !o)}>{title}</button>
      {open && children}
    </section>
  );
}
TSX
// app/orders/page.tsx (Server Component)
import { Collapsible } from '@/app/components/collapsible';
import { OrderHistory } from './order-history'; // async Server Component

export default function Page() {
  return (
    <Collapsible title="Order history">
      <OrderHistory />
    </Collapsible>
  );
}

OrderHistory still runs on the server. Collapsible only decides whether to show its already-rendered output.

Context in a Server Component tree

Context does not work in Server Components, but you can provide it from one. Create the context in a 'use client' module. Since React 19.3, a Server Component can render that context directly as a provider, without a separate client wrapper:

TSX
// user-context.tsx
'use client';
import { createContext } from 'react';

export type User = { id: string; name: string };
export const UserContext = createContext<User | null>(null);
TSX
// app/layout.tsx (Server Component)
import type { ReactNode } from 'react';
import { UserContext } from './user-context';
import { getCurrentUser } from '@/lib/auth';

export default async function Layout({ children }: { children: ReactNode }) {
  const user = await getCurrentUser();
  return <UserContext value={user}>{children}</UserContext>;
}

On older React versions, wrap the provider in a small Client Component instead. Remember the value still has to be serializable.

Streaming data with Promises

Promises are serializable, so a Server Component can start a fetch and let a Client Component read it with use() inside Suspense. I walk through use in React 19 hooks explained.

TSX
// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { getStats } from '@/lib/stats';
import { StatsChart } from './stats-chart';

export default function Page() {
  const statsPromise = getStats(); // not awaited
  return (
    <Suspense fallback={<p>Loading chart...</p>}>
      <StatsChart statsPromise={statsPromise} />
    </Suspense>
  );
}
TSX
// app/dashboard/stats-chart.tsx
'use client';
import { use } from 'react';

type Stats = { label: string; value: number }[];

export function StatsChart({ statsPromise }: { statsPromise: Promise<Stats> }) {
  const stats = use(statsPromise);
  return (
    <ul>
      {stats.map((s) => <li key={s.label}>{s.label}: {s.value}</li>)}
    </ul>
  );
}

Keeping server code on the server

Server Components make it easy to import a module with secrets. They also make it easy to accidentally import that module from a client file. In Next.js, add the server-only package to modules that must never reach the browser:

TypeScript
// lib/db.ts
import 'server-only';
// ...database client with credentials

If a Client Component imports it, the build fails. Also keep React and Next.js patched. In December 2025 the React team disclosed a critical remote code execution vulnerability in the RSC protocol, with follow-up fixes after it, and Next.js has shipped regular security releases since. RSC is a network protocol, so treat framework updates like any other security update.

Common React Server Components mistakes

Mistake Symptom Fix
'use client' on a root layout Huge client bundle, no server data fetching below it Push the directive down to leaf components
Passing an inline function prop from a Server Component "Functions cannot be passed directly to Client Components" error Define it in the Client Component or use a Server Function
Importing a Server Component into a client file It becomes client code, async fails Pass it in as children
Using useState in a file without 'use client' Build error Add the directive or split the component
Thinking 'use server' makes a Server Component Every export becomes a callable endpoint Use no directive for Server Components
Passing ORM class instances as props Serialization error Map to plain objects
Reading window during render in a Client Component Crash during SSR Read it in an effect or handler

How this plays out in a real app

Mercury 360, an AI HR platform I built with Next.js, relies on pre-rendering with background revalidation, and that only works well when the data-heavy parts of a page are Server Components. The rule I follow on projects like that: pages, layouts and data sections stay on the server, and interactivity lives in small client islands such as filters, forms and modals. When a feature needs state, add a client leaf instead of converting its parent. Caching is a separate layer on top of this model, which I break down in Next.js caching explained. If you are coming from the Pages Router, the App Router migration guide maps the old data-fetching functions onto this model.

FAQ

What is the difference between server and client components?

Server Components run only on the server (at build time or per request) and send their rendered output to the browser, not their code. Client Components are also pre-rendered to HTML, but their JavaScript ships to the browser so they can use state, effects and event handlers.

Does 'use client' mean the component only renders in the browser?

No. Client Components are still server-rendered to HTML in frameworks like Next.js. The directive marks a boundary in the module graph: that file and everything it imports become part of the client bundle and are hydrated in the browser.

Can I pass a function from a Server Component to a Client Component?

Only if it is a Server Function marked with 'use server'. Regular functions, class instances and unregistered symbols cannot be serialized across the boundary.

Can a Client Component render a Server Component?

It cannot import one, but it can receive one as a prop such as children. The Server Component renders on the server and the Client Component places the result wherever it renders children.

Is 'use server' the same as a Server Component?

No. Server Components need no directive because they are the default. 'use server' marks Server Functions, which are async functions the client can call over the network.

Wrapping up

Server Components get much easier once you stop thinking in terms of "this renders on the server, that renders on the client" and start thinking in terms of "which code ships to the browser". Default to the server, add 'use client' at the smallest interactive leaf, and pass only plain data across the line. Get those three habits right and most RSC errors never happen.

  • #react
  • #server-components
  • #nextjs
  • #rsc