The Mental Model I Use for React Server Components
ReactNext.jsPerformance

The Mental Model I Use for React Server Components

December 20, 20258 min read

React Server Components broke my mental model of React. Not because the API is complicated - it isn't - but because I kept trying to understand them as a variation of what I already knew.

Components render. Server components render on the server. Client components render on the client. Seemed simple, but I kept running into confusing errors and unexpected behavior until I stopped thinking about components and started thinking about render boundaries.

The Old Mental Model (Wrong)

My first attempt: "Server components are like components but they run on the server and can't use hooks."

This led to questions like: "Why can't I pass a Server Component as a prop to a Client Component?" and "Why does making one component a Client Component seem to make everything under it a Client Component too?"

The component-centric model doesn't explain these things cleanly.

The Render Boundary Mental Model

Here's the reframe: React renders in two passes - a server pass and a client pass. The question isn't "what type is this component" but "which pass does this code participate in?"

  • Server pass: Runs once, on the server, at request time. Outputs a serializable tree.
  • Client pass: Runs in the browser. Hydrates and makes the tree interactive.

A "Client Component" isn't a component that only runs in the browser - it's a component that participates in the client pass (it still server-renders for the initial HTML). The 'use client' directive marks a boundary - everything below this point in the import tree joins the client bundle.

This explains the confusing behaviors:

Why can't you import a Server Component into a Client Component? Because the client bundle can't contain server-only code (db queries, file system access, etc.). The 'use client' boundary is a one-way door - once you cross it, everything that gets imported must be safe to run in the browser.

But you CAN pass Server Components as props/children. Because props are serialized as data - they cross the boundary as values, not as code. A Client Component receiving children from a Server Component isn't importing it; it's receiving the already-rendered output.

// This works - children cross as serialized output, not as code
export default function Layout({ children }) {
  return <ClientShell>{children}</ClientShell>  // ClientShell has 'use client'
}

// This doesn't work - imports cross the boundary as code
'use client'
import { ServerOnlyComponent } from './ServerOnlyComponent'  // Error

Practical Implications

Data fetching belongs at the render boundary, not deep in the tree.

In the old model, I'd often fetch data in a component and pass it down. With RSC, the pattern is: fetch at the highest server component that needs the data, pass it down as props.

'use client' should be as low in the tree as possible.

Each time you add 'use client', you're pulling that component and everything it imports into the client bundle. The goal is to keep interactivity as leaves, with the structural/data-fetching code staying on the server.

// Worse: entire page becomes client code
'use client'
export default function ProductPage({ id }) {
  const [quantity, setQuantity] = useState(1)
  const product = await db.products.findUnique({ where: { id } })  // Can't do this now
  // ...
}

// Better: server fetches data, client handles interaction
export default async function ProductPage({ id }) {
  const product = await db.products.findUnique({ where: { id } })
  return <ProductView product={product} />  // ProductView has 'use client' for state
}

The Corollary: Suspense Boundaries

Once you have the render boundary model, Suspense makes more sense too. <Suspense> is how you tell React "this part of the tree can load asynchronously,show a fallback while it's pending."

In the App Router, every async Server Component is implicitly suspense-ready. You wrap sections in <Suspense> to control the loading UX at the granularity you want.


The render boundary model isn't the official React framing, but it's been the most useful way for me to reason about where to put components, when to add 'use client', and why the rules are what they are. Once it clicked, the rest of the App Router made sense.

Continue Reading

Related Articles

Zero to Production: Deploying Next.js on Railway
DevOpsNext.jsDeployment

Zero to Production: Deploying Next.js on Railway

Railway removed all the reasons I had to use Vercel. Here's a complete walkthrough including environment variables, Postgres, and custom domains.

Nov 22, 20257 min read
Building Type-Safe APIs with tRPC and Next.js
TypeScriptNext.jsAPI

Building Type-Safe APIs with tRPC and Next.js

End-to-end type safety without code generation. I'll show you how tRPC eliminates the API contract problem entirely and why it's become my go-to for full-stack TypeScript projects.

Aug 18, 20257 min read
Ranked #1 in Speed Coding Challenge
Featured
CareerDeveloper ToolsPerformance

Ranked #1 in Speed Coding Challenge

A deep dive into how curiosity, client-side analysis, and cloud computing helped me climb to the top of Toptal's JS leaderboard.

May 1, 20254 min read