Frontend

Server Components vs Client Components: The Mental Model That Actually Sticks

How the RSC boundary works: two module graphs, what crosses through imports and props, and the errors that name the rule you broke.

Aarav Sharma

Aarav Sharma

September 14, 202619 min read
Share
Server Components vs Client Components: the mental model that actually sticks

React Server Components stop being confusing the moment you stop reading the two names as a description of where code runs. A Client Component runs on the server too. A 'use client' directive does not mark one component, it opens a door that everything below it walks through. Most App Router pain traces back to those two facts. You add the directive to silence an error, the error goes away, and three weeks later the client bundle has doubled, a database helper has been pulled into the browser graph, and a component you were sure never left the server is logging into your terminal and the browser console at the same time. This guide covers the two module graphs, the exact rules for what crosses between them, the composition patterns that keep the boundary small, and how to read the error messages as a map instead of an obstacle.

Quick Answer

Every file in the App Router belongs to the server module graph until some file declares 'use client'. That directive marks an entry point into the client graph, not a single component, and every module imported from it joins the client bundle. Two separate things cross the boundary: code crosses through imports, and data crosses through props, which must be serializable. Server Components render on the server only. Client Components render on the server and again in the browser. Keep the directive at the leaves, hand server-rendered output down as children, and the model holds up.

What Are Server and Client Components?

React Server Components split a single component tree across two module graphs. Next.js compiles each graph separately, and a component's graph decides whether its source code is ever sent to a browser.

  • Server Components run only on the server. Their code never ships, so they can read a database, touch the filesystem, or use a secret directly during render.
  • Client Components run in both places. They render on the server to produce HTML, then run again in the browser to hydrate that HTML and stay interactive.
  • The server render produces the RSC Payload, a serialized description of the UI that carries references to the Client Components inside it along with their props.
  • The client graph never imports the server graph. It only receives those references and that serialized data.

Layouts and pages are Server Components by default, and there is no directive for that. Server is the resting state of the system. The Next.js guide to Server and Client Components covers the composition patterns that follow from this default.

The Problem: The Names Describe Shipping, Not Running

Nearly every team hits the same sequence. A component needs useState, the build fails, someone adds 'use client' to the file where the error pointed, and the build passes. Nobody checks what else that file imports.

That is how a shared components/index.ts barrel file, a theme provider, or a root layout ends up carrying the directive. The moment it does, every module reachable through its imports is compiled into the client bundle, including the date library, the icon set, and the analytics wrapper that had no business being there. The build is still green. Nothing tells you.

Three more symptoms come from the same misreading:

  • A console.log inside a Client Component prints in the terminal on a hard refresh, which looks like a bug and is not. The component rendered on the server first.
  • A utility imported at the boundary quietly drags a server SDK into the client graph, and the chain breaks somewhere far from the file you edited.
  • Passing a Prisma record or an onClick handler down from a page throws, and the message names serialization, which sounds unrelated to the change you just made.

None of this is about hooks or interactivity. It is about which graph a module landed in, and the directive is the only thing that decides.

How a use client directive pulls every imported module into the client bundle

The Solution: One Tree, Two Graphs, One Boundary

Think of the boundary as a wall with exactly two openings in it, one for code and one for data.

Server module graph  (pages, layouts, data access, secrets)
|
| renders to
v
RSC Payload  ->  serialized UI + references to Client Components + their props
|
| crosses the network
v
Client module graph  ('use client' entry points and everything they import)
|
| hydrates
v
Interactive DOM in the browser
The boundary between the server and client module graphs in React Server Components

Three rules cover almost every situation:

  • 'use client' declares an entry point, not a component. You write it once, at the top of the subtree.
  • Code crosses through imports. Anything a client file imports becomes client code.
  • Data crosses through props, and only serializable data makes it. Functions do not, with one deliberate exception.

How the Boundary Actually Works

Where each type renders

On the server

In the browser

Server Component

Yes

No

Client Component

Yes

Yes

The word "Client" means the component also runs in the browser, not that it runs there exclusively. On a direct visit, a Client Component renders on the server to generate HTML, then renders again during hydration. On a client-side navigation there is no HTML step, so the server sends an RSC Payload and the component renders in the browser alone.

Rendering a Client Component on the server produces HTML, but it stays a Client Component. Server-rendered describes how the HTML was made. Server Component describes where the code lives and whether it ships.

The Server and Client Boundary reference documents this split in full, including how the RSC Payload is assembled and what the compiler does when client code reaches the server graph.

How the RSC payload carries serialized UI and client references to the browser

What code crosses

Imports, and nothing else. Once a file carries the directive, the modules it imports and the components it renders directly are part of the client bundle. You do not repeat the directive further down.

The exception is what makes composition work: this does not apply to Server Components passed in as children or as any other prop. Those are never imported into the client module graph, so their code never ships.

What data crosses

Props, and they have to be serializable. React accepts more than JSON here, and the exact list matters when you are debugging.

Crosses fine: strings, numbers, booleans, null, undefined, BigInt, globally registered symbols, arrays, Map, Set, typed arrays, Date, FormData, plain objects made with an object initializer, promises, and rendered React elements.

Does not cross: functions, class instances, objects with a null prototype, symbols created with Symbol(), and event objects. A Prisma model, a Mongoose document, a Mongo ObjectId, and a Decimal wrapper all fall in this group because they are class instances carrying methods.

React keeps the authoritative list in the serializable arguments and return values section of the 'use server' reference.

One asymmetry catches people out. React elements are valid props for a Client Component, but they are not valid arguments to a Server Function. The two lists are close enough to look identical and different in exactly that spot.

The function exception

A plain function cannot be a prop. A Server Function marked with 'use server' can, because it crosses as a reference rather than as code, and calling it from the browser makes a network request back to the server.

Nothing in the type system distinguishes the two. The Next.js TypeScript plugin works around this with a naming convention: a function-typed prop on a Client Component is allowed when the prop is called action or ends in Action, and flagged otherwise.

Owner and parent

When a page renders <Modal><Cart /></Modal>, the page is the owner of both, because its source contains the JSX. The modal is only the parent of the cart in the rendered tree. Ownership decides the graph, so Cart runs on the server even though a Client Component displays it. The modal receives the cart's output, never its code.

This distinction is easy to miss, but it is what makes the whole pattern work. The modal can stay interactive without pulling the Server Component’s code into the browser bundle.

Prerequisites

  • Node.js 20.9 or later for Next.js 16, or 18.18 and up if you are still on 15
  • A Next.js App Router project on React 19
  • Comfort with TypeScript and the basics of module resolution
  • A rough sense of your current client bundle size, so you can tell whether any of this helped

Step-by-Step Implementation

Step 1: Start on the server and stay there

Write the page as a Server Component and fetch inside it. There is no loader step and no useEffect, so there is also no loading flash on first paint.

// app/orders/page.tsx
import { getOrders } from '@/lib/data'
import { OrderTable } from './order-table'
export default async function OrdersPage() {
  const orders = await getOrders()   // runs on the server, during render
  return <OrderTable orders={orders} />
}

getOrders can hold a connection string and a raw SQL query. None of it reaches the browser, and no API route has to exist to expose it.

Step 2: Push the directive down to the leaves

Interactivity usually lives in a small part of the UI. Mark that part, not its container.

// app/orders/filter-input.tsx
'use client'
import { useState } from 'react'
export function FilterInput({ onQueryChange }: { onQueryChange: (q: string) => void }) {
  const [query, setQuery] = useState('')
  return (
    <input
    value={query}
    onChange={(e) => {
      setQuery(e.target.value)
      onQueryChange(e.target.value)
    }}
  />
)
}

The use client directive reference spells out the placement rules, including why the directive has to sit above every import in the file.

A useful habit: before adding the directive to a file, look at its import list and ask which of those imports you are willing to ship. If the answer is "not that one", the directive belongs somewhere lower.

Step 3: Pass server output as children instead of importing it

This is the pattern that does most of the work. A Client Component can wrap, position, and toggle server-rendered content it never imports.

// app/dashboard/page.tsx
import { Cart } from '@/app/ui/cart'      // Server Component
import { Modal } from '@/app/ui/modal'    // Client Component
export default function Page() {
  return (
    <Modal title={<h2>Your cart</h2>}>
    <Cart />
  </Modal>
)
}
// app/ui/modal.tsx
'use client'
import { useState, type ReactNode } from 'react'
export function Modal({ title, children }: { title: ReactNode; children: ReactNode }) {
  const [open, setOpen] = useState(true)
  if (!open) return null
  return (
    <div role="dialog">
    <header>
    {title}
    <button onClick={() => setOpen(false)}>Close</button>
  </header>
  {children}
</div>
)
}

Children are not special here. The title behaves the same way. Both arrive as serialized elements, and the modal places them wherever its own markup says.

The same trick fixes the provider problem. A context provider has to be a Client Component, but wrapping the app in one does not push the app into the client graph, as long as the tree arrives through children.

Passing a Server Component as children into a Client Component

Step 4: Serialize deliberately at the boundary

Do not hand ORM records straight to a Client Component. Map them to plain objects and cut the fields the browser does not need while you are there.

const orders = await db.order.findMany()
const view = orders.map((o) => ({
  id: o.id,
  total: o.total.toNumber(),        // Decimal is a class instance
  placedAt: o.placedAt,             // Date is fine as-is
  customer: o.customer.displayName, // drop the email, the browser has no use for it
}))
return <OrderTable orders={view} />

That mapping doubles as a security checkpoint. Props are sent to the browser, so anything you forget to strip is readable by the user and by anyone looking at their network tab.

Step 5: Stream a promise instead of blocking on it

A Server Component does not have to await everything before returning. Start the request on the server, pass the pending promise down, and let the Client Component read it with use.

// app/page.tsx
import { Suspense } from 'react'
import { getRecommendations } from '@/lib/data'
import { Recommendations } from './recommendations'
export default function Page() {
  const promise = getRecommendations()   // started, not awaited
  return (
    <Suspense fallback={<Skeleton />}>
    <Recommendations promise={promise} />
  </Suspense>
)
}
// app/recommendations.tsx
'use client'
import { use } from 'react'
export function Recommendations({ promise }: { promise: Promise<Item[]> }) {
  const items = use(promise)
  return <List items={items} />
}

The shell renders immediately, the nearest Suspense boundary shows a fallback, and the request was already in flight before any browser code ran. You still fetch in the browser when the data depends on something only the browser knows, like a value the user just typed.

Step 6: Mutate with Server Functions, not API routes

A Server Function marked 'use server' gives you a mutation endpoint without writing a route handler, and forms wired to one work before the JavaScript bundle has loaded.

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
export async function archiveOrder(formData: FormData) {
  const user = await auth()
  if (!user) throw new Error('Unauthorized')
  const id = String(formData.get('id'))
  await db.order.update({ where: { id, userId: user.id }, data: { archived: true } })
  revalidatePath('/orders')
}

Arguments arrive from the client and are fully client controlled, so validate and authorize inside the function every time. The check belongs in the function body, not in the component that renders the button.

Outside a <form>, call it inside a transition so you get a pending state and error handling. Forms wrap it in a transition for you. React's 'use server' reference covers the security expectations for Server Functions in more depth.

Step 7: Fence the boundary so mistakes fail loudly

Import rules are easy to state and easy to violate silently. Two packages turn a silent violation into a build error. npm install server-only client-only

// lib/db.ts
import 'server-only'
// now any client file that imports this, directly or through a chain, fails the build

Use client-only in the other direction, for modules that touch window or document. When a shared utility gets imported from both sides, split it: the server half keeps the server SDK, and a genuinely shared half holds only logic that runs anywhere.

Step 8: Verify what you shipped

  • Run the bundle analyzer and look at what sits inside your 'use client' entry points.
  • Put a console.log in a Client Component, hard refresh, and confirm it appears in both the terminal and the browser console. That is the model working, not a bug.
  • Navigate client-side to the same page and confirm the terminal stays quiet.
  • View source on a Server Component page and check the content is present in the HTML, since a crawler that runs no JavaScript sees only that.
  • Search the codebase for 'use client' and read every hit. If one sits in a layout, a barrel file, or a provider module, that is your first thing to fix.
Bundle analyzer output showing modules inside a client entry point

Common Problems and Errors

"You're importing a component that needs useState"

A hook, an event handler, or a browser API landed in the server graph. The fix is a directive, but put it on the smallest file that needs it. If the error points at a barrel file, stop and import the component from its own module instead, because a directive on the barrel takes every re-export with it.

"Only plain objects, and a few built-ins, can be passed to Client Components"

A class instance or a null-prototype object is in the props. Usually an ORM record, a Mongo ObjectId, or a Decimal. Map to a plain object at the boundary. In development the message includes the offending prop path, which is faster to read than guessing.

The Only plain objects can be passed to Client Components error in the terminal

"Event handlers cannot be passed to Client Component props"

A function crossed from a Server Component. Either the handler belongs inside the Client Component, or the operation is a mutation and should be a Server Function. Renaming the prop to action does not make an ordinary function crossable, it only tells the TypeScript plugin to stop warning, so make sure the function actually carries 'use server'.

"Element type is invalid" from a compound component

Static properties do not survive the crossing. When a Server Component imports a Client Component it receives a client reference, not the function, so Tabs.Panel is undefined. Either use the compound component from another Client Component, or expose the pieces as named exports.

The bundle did not shrink

Almost always a directive placed too high. Follow the imports downward from every entry point. A provider or barrel file at the root is the usual culprit, and moving the boundary below it often removes more weight than any amount of code splitting.

Hydration mismatch on a Client Component

A hydration mismatch happens when the HTML rendered on the server doesn't match what React renders during the browser's initial render. Values such as Date.now(), Math.random(), or data from localStorage and window can cause this when they are used during render because the server and browser may return different results. Read browser-only values inside an effect, or render a stable initial value and update it after the component mounts.

A secret showed up in the page source

Props are serialized into the payload, so a field you passed but never displayed is still in the response. Strip at the boundary rather than at the point of use.

Best Practices

  • Default to Server Components and treat every 'use client' as a decision that needs a reason.
  • Put the directive at the leaf that needs it, then check what that file imports.
  • Compose through children and element props instead of importing Server Components into client files.
  • Shape props into plain objects at the boundary and drop fields the browser does not need.
  • Mark data-access modules with server-only so a bad import fails at build time.
  • Authorize inside Server Functions, never in the component that renders the trigger.
  • Reach for built-in browser behavior before a Client Component. A <details> element, a <video controls>, and a <form action> need no JavaScript from you.
  • Keep third-party client libraries behind a thin wrapper you own, so the boundary sits in your code and not in node_modules.

Security and Performance Considerations

Props are a public API

Everything passed to a Client Component ends up in the RSC Payload and travels to the browser. An internal note, a hashed password, an admin flag, a partner's contact details: if it is in the props object, it is readable. The mapping step in Step 4 is the enforcement point, and it is worth reviewing in code review the same way you would review an API response shape. The Next.js Data Security guide goes through the leak paths in detail.

React ships experimental taint APIs, experimental_taintUniqueValue and experimental_taintObjectReference, that make a specific value or object throw if it ever reaches client code. They are a useful backstop for a small number of high-value secrets, not a general policy.

Where the cost actually is

Server Components remove code from the bundle, which helps parse and execution time on low-end devices more than it helps transfer size. What they do not remove is the payload itself: a large server-rendered tree still serializes into a large response. Sending fewer rows beats sending a smaller bundle of the code that renders them.

Identical fetch calls are memoized within a single server render, so several components asking for the same resource produce one request. Across renders, that is what Cache Components and the use cache directive are for, and caching in Next.js 16 is opt-in rather than something you have to disable.

SEO and the first response

A crawler that reads HTML without running JavaScript sees the first response, and both Server and Client Components contribute to it. What that crawler cannot see is anything gated behind interaction. Content that only appears after a click is not in the HTML, regardless of which component type rendered the button.

Server Components vs Client Components

Server Component

Client Component

Code shipped to the browser

No

Yes

Renders on the server

Yes

Yes

Re-renders in the browser

No

Yes

State, effects, event handlers

No

Yes

Direct database or filesystem access

Yes

No

Browser APIs such as window

No

Yes

React Context

Cannot create or consume

Yes

Marked by

Nothing, it is the default

'use client' at the file top

Updated by

Rendering the route again on the server

Local state change or hydration

When Should You Use Each?

Reach for a Server Component when:

  • The component reads data, and that data lives in a database, a file, or an internal service.
  • Secrets or credentials are involved.
  • The output is content: a table, an article body, a product description.
  • The dependency it needs is large and only used to render, such as a markdown parser or a syntax highlighter.

Reach for a Client Component when:

  • The UI holds state that changes over time: a controlled input, a live filter, a drag handle, an open or closed menu.
  • You need an event handler, an effect, or a browser API.
  • You are consuming context, or a library that does.
  • The interaction has to feel instant and cannot wait for a round trip.

When it is genuinely ambiguous, start on the server. Moving a component to the client later is a one-line change. Untangling a boundary that was drawn too high is not.

Frequently Asked Questions

Do Client Components run only in the browser?

No. On a direct visit or a refresh they render on the server first to produce HTML, then run again in the browser to hydrate. Only on a client-side navigation do they render in the browser alone.

Does 'use client' need to go in every file that uses hooks?

No. It marks an entry point. Every module imported from that file is already part of the client graph and inherits the boundary, so adding it repeatedly is noise.

Can a Client Component render a Server Component?

Not by importing it. It can render one passed in as children or as any other prop, because the element arrives as serialized output rather than as code. That is the standard interleaving pattern.

Why can't I pass a function as a prop?

Props are serialized to cross the network, and a function has no serialized form. Server Functions are the exception: they cross as a reference, and invoking one from the browser makes a request back to the server.

Do Server Components make my app faster?

They usually reduce the JavaScript the browser has to parse and execute, which matters most on slow devices. They do not automatically make data fetching faster, and a bloated payload can undo the gain. Measure both bundle size and response size.

Can I use React Context in a Server Component?

No. Context is a client feature. Create the provider in a Client Component, then wrap your tree with it through children so the tree itself stays on the server.

Is useEffect still needed?

Yes, for anything that reacts to browser state: subscriptions, measurements, focus management, external stores. What it is no longer needed for is fetching initial data, which now belongs in the Server Component that renders the page.

Conclusion

The mental model is smaller than the discourse around it. There are two module graphs. 'use client' marks the entry to the second one. Code crosses through imports, so whatever a client file imports gets shipped. Data crosses through props, so whatever you pass gets serialized and read. Everything else follows from those three sentences: why the directive belongs at the leaves, why children works when importing does not, why a Prisma record throws, why a secret in a prop is a leak.

Hold that model and the error messages become directions. Each one names the rule you broke and points at the file where you broke it, which is a better developer experience than most frameworks manage for a boundary this consequential.

Need Help Drawing the Boundary in Your App?

If your client bundle is not shrinking the way the migration promised, or your 'use client' directives have crept up into layouts and providers, our team can audit the boundary, restructure the component tree, and get the architecture back to something your team can reason about. Get in touch to talk through your project.

Aarav Sharma

Aarav Sharma

Lead Software Engineer

Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts