React Server Components vs Client Components: A Mental Model
Frontend14 min read

React Server Components vs Client Components: A Mental Model

Server and Client Components solve different problems. This guide builds a clear mental model of the server/client boundary, the serialization contract between them, composition patterns, and how it all maps to the Next.js App Router.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Mar 18, 2026
#React#React Server Components#Next.js#App Router#Frontend Architecture#Performance#TypeScript

React Server Components (RSC) are components that run exclusively on the server, produce their output there, and send a compact UI description to the browser instead of their source code, while Client Components are the traditional interactive components that hydrate and run in the browser. Because a Server Component ships zero JavaScript to the client, heavy dependencies like markdown parsers stay on the server, and data fetching happens next to the database instead of through client-side waterfalls. The two component types are separated by the "use client" directive, which marks the boundary, and everything crossing that boundary must be serializable: no functions, class instances, or event handlers as props. This guide builds a complete mental model of the server/client split, covering composition patterns, the serialization contract, data fetching, streaming with Suspense, common mistakes, and how the model maps onto the Next.js App Router.

What Problem Do Server Components Actually Solve?

For years, the default React model was: ship a JavaScript bundle to the browser, hydrate it, and let the client do everything, including fetching data. This produced two chronic problems. First, bundle bloat: every date-formatting library, every markdown parser, every syntax highlighter you imported got shipped to the user, even when it was only needed to produce static output. Second, data-fetching waterfalls: the browser had to download JS, execute it, discover it needed data, then make a round trip to the server, often nested several layers deep.

Server Components attack both problems at their root. A Server Component runs on the server, produces its output there, and sends that output (not its source code) to the client. The heavy markdown parser stays on the server. The database query happens right next to the database. The client receives a compact description of the UI, and only the genuinely interactive parts arrive as JavaScript.

The core insight: not every component needs to be interactive. A large portion of most UIs is static presentation of data. Server Components let that portion cost zero client-side JavaScript.

Two Environments, One Component Model

The single most important thing to understand is that there are now two kinds of components, distinguished by where they execute:

The two component types:

  • Server Components run only on the server (during the request or at build time). They render once, produce output, and never re-render on the client. They are the default in the App Router.
  • Client Components run on the server for the initial HTML and then again in the browser, where they hydrate and become interactive. They are marked with the "use client" directive.

Notice that "Client Component" does not mean "does not run on the server." Client Components still participate in server-side rendering to produce initial HTML. The distinguishing property is that their code is also shipped to the browser and re-executed there. A Server Component, by contrast, never ships its code and never runs in the browser at all.

The "use client" Directive: Marking the Boundary

A Server Component becomes a Client Component the moment you put the "use client" directive at the top of the file. This directive is not a per-component switch; it marks an entry point into the client bundle. Every module imported by a "use client" file (and everything they import) is considered part of the client graph.

tsx
'use client';

import { useState } from 'react';

// This is a Client Component. It ships to the browser,
// hydrates, and can use hooks and event handlers.
export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Clicked {count} times
    </button>
  );
}

The directive is a boundary, not a leaf. Once you cross into the client world, you stay there for everything below that import chain, unless you use a specific composition trick we will cover shortly. Think of "use client" as a door: you open it once, and everyone who walks through the imports behind it is now in the client house.

What Can and Cannot Run in Each Environment?

Because the environments differ, so do their capabilities. Server Components have access to server-only resources; Client Components have access to the browser and interactivity. Trying to use a capability in the wrong place is the source of most RSC errors.

Server Components CAN:

  • Be async functions and await data directly (databases, file system, internal services)
  • Read secrets and environment variables safely (nothing is shipped to the client)
  • Import heavy dependencies without adding to the client bundle
  • Render other Server Components or Client Components

Server Components CANNOT:

  • Use useState, useEffect, useReducer, or any hook that relies on client runtime state
  • Attach event handlers like onClick or onChange
  • Use browser-only APIs such as window, localStorage, or the DOM
  • Use React Context that depends on client-side providers for interactivity

Client Components CANNOT:

  • Be async components that await data at the top level
  • Directly access the database, file system, or private server secrets
  • Import server-only modules without leaking them into the browser bundle

The asymmetry is deliberate. Server code that reaches for browser APIs will fail because there is no window on the server. Client code that reaches for secrets is a security hole, because anything a Client Component imports could end up readable in the browser. To make this failure loud instead of silent, mark truly server-only modules with the server-only package so an accidental client import fails the build.

typescript
// lib/db.ts
import 'server-only';

// If any Client Component tries to import this module,
// the build fails immediately instead of leaking the
// connection string into the browser bundle.
export async function getUserById(id: string) {
  const connectionString = process.env.DATABASE_URL;
  // ... query the database using a server-only client
  return db.query('SELECT * FROM users WHERE id = $1', [id]);
}

What Is the Serialization Boundary?

When a Server Component renders a Client Component and passes props, those props must cross from the server to the client. They travel as serialized data in the RSC payload. This is the single constraint that trips up most developers: props passed from a Server Component to a Client Component must be serializable.

You CAN pass across the boundary:

  • Primitives: strings, numbers, booleans, null, undefined, bigint
  • Plain objects and arrays composed of serializable values
  • Dates, Maps, Sets, and typed arrays
  • JSX / React elements (including rendered Server Components)
  • Server Actions (functions marked with "use server")
  • Promises (they stream and resolve on the client)

You CANNOT pass across the boundary:

  • Arbitrary functions and event handlers (except Server Actions)
  • Class instances with methods and prototypes
  • Symbols that are not globally registered
tsx
// Server Component
async function ProductPage({ id }: { id: string }) {
  const product = await getProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      {/* OK: serializable props cross the boundary cleanly */}
      <AddToCartButton
        productId={product.id}
        price={product.price}
      />

      {/* NOT OK: a function is not serializable.
          <AddToCartButton onAdd={() => save(product)} />
          This throws: functions cannot be passed to Client Components. */}
    </div>
  );
}

When you feel the urge to pass a callback down, the answer is usually a Server Action or moving the logic inside the Client Component itself. The boundary is data-in, not behavior-in.

Composition: Passing Server Components as Children

Here is the most valuable pattern in the entire RSC model, and the one that resolves the "but everything below use client becomes client" fear. A Client Component cannot import a Server Component (that would force the server code into the client bundle). But a Client Component can accept a Server Component as a child or prop.

The reason this works: children are rendered by the parent Server Component and passed to the Client Component already as elements. The Client Component never imports the server code; it just receives an opaque, already-rendered slot to place in its tree.

tsx
'use client';

import { useState } from 'react';

// A Client Component that provides interactivity (a collapsible shell)
// but stays agnostic about what it wraps.
export function Collapsible({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <section>
      <button onClick={() => setOpen((o) => !o)}>
        {open ? 'Hide' : 'Show'} details
      </button>
      {open && <div>{children}</div>}
    </section>
  );
}
tsx
// Server Component (page.tsx) composes them together.
// ExpensiveServerReport stays a Server Component: its heavy
// dependencies and data access never reach the browser.
import { Collapsible } from './Collapsible';
import { ExpensiveServerReport } from './ExpensiveServerReport';

export default async function Page() {
  return (
    <Collapsible>
      {/* Rendered on the server, passed in as an element */}
      <ExpensiveServerReport />
    </Collapsible>
  );
}

Internalize this pattern: interactivity wraps content, it does not consume it. Keep Client Components thin and push them to the leaves. Wrap server-rendered content in client shells via the children prop rather than pulling the content into the client boundary.

When Should You Reach for a Client Component?

The default should be Server Components. Reach for a Client Component only when you need something the browser runtime provides. A useful rule: if the component reacts to the user or the browser, it belongs on the client.

Legitimate reasons to use "use client":

  • State and interactivity: useState, useReducer, form controls, toggles
  • Effects and lifecycle: useEffect for subscriptions, timers, or measuring the DOM
  • Event handlers: onClick, onChange, onSubmit, drag-and-drop
  • Browser-only APIs: localStorage, matchMedia, geolocation, IntersectionObserver
  • Client-only libraries: most animation, charting, and map libraries that touch the DOM
  • Using React Context providers that hold interactive state

A common trap is marking a whole page "use client" because one button needs an onClick. Instead, extract that button into its own small Client Component and keep the page a Server Component.

How Does Data Fetching Work in Server Components?

Data fetching is where Server Components shine. Because they run on the server and can be async, you fetch data directly in the component body with await, no useEffect, no loading-state boilerplate, no client-side data library required for the initial load.

tsx
// Server Component: fetch inline, no useEffect, no client state.
async function Dashboard({ userId }: { userId: string }) {
  // These run on the server, close to the data source.
  const user = await getUserById(userId);
  const invoices = await getInvoices(userId);

  return (
    <main>
      <h1>Welcome back, {user.name}</h1>
      <InvoiceList invoices={invoices} />
    </main>
  );
}

To avoid sequential waterfalls when requests are independent, kick them off together and await them in parallel. This is a plain JavaScript concern, not an RSC-specific API.

tsx
async function Dashboard({ userId }: { userId: string }) {
  // Start both requests before awaiting either -> parallel.
  const userPromise = getUserById(userId);
  const invoicesPromise = getInvoices(userId);

  const [user, invoices] = await Promise.all([
    userPromise,
    invoicesPromise,
  ]);

  return (
    <main>
      <h1>Welcome back, {user.name}</h1>
      <InvoiceList invoices={invoices} />
    </main>
  );
}

Streaming and Suspense

Server Components integrate natively with Suspense to enable streaming. Instead of blocking the entire response until the slowest data source resolves, you wrap the slow part in Suspense, send the rest of the page immediately, and stream the slow chunk in when it is ready. The user sees meaningful content faster, and each async boundary resolves independently.

tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <main>
      {/* Renders instantly */}
      <Header />

      {/* Streams in when its data resolves; a fallback shows meanwhile */}
      <Suspense fallback={<ReportsSkeleton />}>
        <SlowReports />
      </Suspense>

      {/* Independent boundary: resolves on its own timeline */}
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </main>
  );
}

async function SlowReports() {
  const reports = await getReportsSlowly(); // e.g. a heavy aggregation
  return <ReportsTable reports={reports} />;
}

The mental model for streaming: each Suspense boundary is a promise the server can fulfill later. The initial HTML ships with fallbacks in place, and React swaps in the real content over the same connection as it becomes ready. This is what makes RSC feel fast even with slow upstream data.

Common Mistakes and Mental Traps

Pitfalls to watch for:

  • Marking too much as "use client": one interactive widget does not require the whole page to be a Client Component. Push the boundary down to the leaf.
  • Trying to await data in a Client Component: Client Components cannot be async. Fetch in a Server Component and pass the data down, or use a Server Action.
  • Passing functions across the boundary: only Server Actions may cross as callable props. Everything else must be plain, serializable data.
  • Importing a Server Component into a Client Component: this pulls server code into the browser. Pass it as children instead.
  • Leaking secrets: reading process.env in a module that a Client Component imports can expose it. Use the server-only package to guard sensitive modules.
  • Assuming Client Component means client-only: it still renders on the server for the initial HTML, so guard browser APIs (for example, check typeof window) or run them inside useEffect.
  • Overusing useEffect for data: in the App Router, initial data belongs in Server Components, not in client effects.

How Does It Map to the Next.js App Router?

The Next.js App Router is the most complete production implementation of the RSC model, so its conventions are worth mapping directly onto the mental model above.

App Router conventions:

  • Everything under the app directory is a Server Component by default. You opt into the client explicitly with "use client".
  • page.tsx, layout.tsx, and most files are Server Components unless marked otherwise, so they can be async and fetch data directly.
  • loading.tsx is sugar over a Suspense boundary: it provides the fallback UI while the route segment streams.
  • Server Actions ("use server" functions) let Client Components trigger server logic (mutations, form handling) without you writing an API route by hand.
  • The extended fetch in Next.js adds caching and revalidation on top of the standard Server Component data-fetching model.
tsx
// app/products/[id]/page.tsx
// Server Component by default: async, direct data access, no bundle cost.
import { AddToCartButton } from './AddToCartButton'; // 'use client'

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

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* Only this leaf is interactive and shipped to the browser */}
      <AddToCartButton productId={product.id} />
    </article>
  );
}
tsx
// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

// A Server Action: callable from a Client Component, runs on the server.
export async function addToCart(productId: string) {
  await cart.add(productId);
  revalidatePath('/cart');
}

With this mapping, the App Router stops feeling like a pile of special files and starts reading as a direct expression of the server/client model: server by default, client at the interactive leaves, actions to cross back to the server, and Suspense to stream.

Key Takeaways

Carry these with you:

  • There are two environments now: Server Components run only on the server and ship no code; Client Components run on the server for initial HTML and again in the browser.
  • "use client" marks a boundary, not a single component. Everything imported beyond it joins the client bundle.
  • Server Components can be async and access secrets, databases, and heavy dependencies; they cannot use hooks, state, event handlers, or browser APIs.
  • Props from server to client must be serializable. Functions do not cross the boundary, except Server Actions.
  • Compose by passing Server Components as children of Client Components to keep server code out of the browser.
  • Default to Server Components; reach for Client Components only for interactivity, effects, and browser APIs, and keep them at the leaves.
  • Fetch data in Server Components, parallelize independent requests, and stream slow parts with Suspense.
  • The Next.js App Router is this model in practice: server by default, "use client" to opt in, Server Actions to mutate, and loading.tsx / Suspense to stream.

Share this article

Related Articles