The RSC Mental Model: Server vs. Client Boundaries

If you learned React in the SPA era, your intuition is that components are JavaScript functions that run in the browser, attach event handlers, and re-render when state changes. React Server Components keep the component model but split execution across two environments: a server runtime that can access secrets, databases, and heavy libraries, and a client runtime that owns interactivity, browser APIs, and local state.

Frameworks such as the Next.js App Router make this split explicit: by default, modules in the app directory are Server Components. That means their output can be serialized into the RSC payload and HTML without sending their implementation—or their dependencies—to the client. You opt into the client with a single directive.

The three directives that matter

  1. No directive (default in App Router) — Server Component. Async components are allowed. You can await database calls, read the filesystem (where appropriate), and import server-only modules.

  2. 'use client' — Everything in that module’s dependency graph that this entry re-exports or composes becomes part of the client bundle (subject to tree boundaries). Hooks like useState, useEffect, and event handlers live here.

  3. 'use server' — Marks Server Actions: async functions invoked from the client (typically via forms or transitions) but executed on the server. They are RPC-style endpoints with React’s ergonomics, not arbitrary public HTTP handlers you hand-wrote.

The mental shift is: interactivity is opt-in. Every 'use client' file increases what users download and parse. The server tree stays lean; you push boundaries as close to the leaves as possible—buttons, sliders, charts that need ResizeObserver, and so on.

Composition rules (the part everyone gets wrong once)

React enforces a one-way containment rule:

  • Server → Client: A Server Component may import and render a Client Component. The client piece hydrates; the server parent already ran and supplied props (which must be serializable).

  • Client → Server: A Client Component must not import a Server Component module. The client bundle cannot contain something that assumes a Node-only environment or secret imports.

  • The loophole that saves you: A Client Component can receive a Server Component as children or another prop that is already React elements produced on the server. The server rendered the subtree; the client receives an opaque slot. This is how you build layouts where a client shell wraps server-rendered content without violating the import rule.

Picture a product grid: the grid and data loading stay on the server; each “Add to cart” control is a tiny client island. The server component does not need to download the cart logic for the whole page—only the interactive nodes pay that cost.

Zero-JS server trees and direct data access

Because server components are not hydrated as full client components, their code paths do not need to ship to the browser. That is how you get “no JavaScript for this subtree” in production bundles. It also means you can import your database client and query in the component body, as long as you respect your ORM’s connection semantics and pooling in serverless environments.

The example below shows a server-side listing that reads from the database and renders cards. The interactive button is split into a client module.

// Server Component (default in Next.js App Router)
import { db } from '@/lib/db'

export default async function ProductList() {
  const products = await db.product.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    take: 20,
  })
  return (
    <section className="grid grid-cols-3 gap-4">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </section>
  )
}

// Client Component for interactivity
'use client'
import { useState, useTransition } from 'react'
import { addToCart } from '@/actions/cart'

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

  const handleClick = () => {
    startTransition(async () => {
      await addToCart(productId)
      setAdded(true)
      setTimeout(() => setAdded(false), 2000)
    })
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? 'Adding...' : added ? 'Added!' : 'Add to Cart'}
    </button>
  )
}

ProductCard might itself be a Server Component that imports AddToCartButton for the interactive region, or a Client Component that receives plain product props—either pattern is valid as long as you do not import a server-only module from a 'use client' file. TypeScript helps: mark server-only utilities with something like import 'server-only' (Next.js convention) so accidental client imports fail at build time.

Serialization: what may cross the server–client seam

Props that cross into a Client Component must be serializable in the RSC sense: plain objects, arrays, strings, numbers, booleans, null, and specific supported types (for example Date in many setups, depending on your framework version and configuration). You cannot pass functions (except in special cases like Server Actions wired by the framework), class instances with methods, or DOM nodes as props from server to client. That constraint is why “business logic in props” often migrates: either keep the logic on the server and pass data, or move the interactive surface to the client and pass IDs and DTOs.

This is where TypeScript pays rent. Define read models (ProductDTO, CartLineView) shared as types and keep server-only entities (PrismaProductWithJoins) out of client-imported modules. A disciplined types/ folder with import type on the client prevents accidental coupling to ORM definitions while preserving shape safety at the boundary.

Common pitfalls that still trip experienced teams

Hooks on the wrong side: useState, useEffect, useContext consumers, and most browser APIs belong in 'use client' modules. If you need stateful behavior in the middle of a server tree, extract a leaf client component rather than promoting an entire route to the client.

Context scope: React context does not magically bridge server and client. Providers that must drive interactive descendants are typically client components; server content can still render inside those providers when passed as children from the server.

Environment variables: Only variables prefixed for public exposure (for example NEXT_PUBLIC_* in Next.js) belong in client code. Everything else should stay in server modules—treat leaking a secret into a client bundle as a build pipeline failure, not a runtime surprise.

Implied singletons: Database pools, feature-flag SDKs with server keys, and filesystem readers should live in server-only modules. When a shared utility accidentally imports them, the lowest 'use client' boundary that pulls that utility into the graph becomes the failure point—often far from the original mistake.

Why this matters for architecture

Before RSC, the cleanest way to keep secrets off the client was an API route or BFF layer. That is still appropriate for third-party clients, but for your own UI, server components collapse layers: the same type-safe db access you use in scripts can back a React tree, with serialization boundaries at component edges instead of at every REST endpoint.

The cost is thinking in boundaries: serializable props, explicit slots for server output inside client shells, and mutations that flow through Server Actions or other server-mediated channels rather than fetch scattered through useEffect.

Once the mental model clicks, performance and security align. You stop shipping server logic to the browser, you shrink bundles by default, and you draw the client boundary only where the platform requires it—events, animation, local state—while the rest of the page remains a server-rendered, streamable React tree.

If you leave with only one diagram in your head, make it directional: data and rendered elements flow from server parents toward client leaves; events and mutations flow back through explicit channels (Server Actions, navigations, or traditional APIs when third parties are involved). RSC is opinionated about that direction because it mirrors how browsers and hosts already work—React is finally modeling the network split instead of pretending every component ran in the same JS realm.