Web Engineering ·

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

Most explanations of React Server Components focus on syntax without building the right mental model. This article explains the rendering boundary as a serialization boundary, what can and cannot cross it, composition patterns, and common production mistakes.

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

Most explanations of React Server Components lead with the directives: put "use client" at the top of a file and it becomes a Client Component, omit it and it becomes a Server Component by default. That framing is accurate but it explains the mechanism without explaining the model. And when engineers build on a mechanism they don’t fully understand, they hit walls they can’t explain.

The wall usually looks like this: a runtime error about passing non-serializable props, a component that fetches data it already has, an interactivity bug that appears only after hydration, or a bundle that somehow got huge despite using Server Components everywhere. Understanding why these things happen requires a mental model, not just a syntax guide.

The Rendering Boundary Is a Serialization Boundary

Here is the model that matters: Server Components and Client Components run in different processes, at different times. The server renders first. The client hydrates second. Anything that needs to cross from one world to the other must be serialized.

Think of it like a JSON wire. What can travel over JSON? Strings, numbers, booleans, arrays, plain objects, null. What cannot? Functions, class instances, Dates (they serialize to strings but lose their prototype), Sets, Maps, Symbols, circular references, and Promises (unless the framework handles them explicitly).

React’s serialization format is not JSON but the constraints are similar. When a Server Component passes props to a Client Component, those props must be serializable. React RSC payload adds a few extras like server references (for Server Actions) and lazy references, but the core rule holds: you cannot pass a live function, a database connection, or an event emitter from a Server Component to a Client Component as a prop.

// server-component.tsx (no directive, runs on server)
import { db } from "@/lib/db";
import { ClientButton } from "./client-button";

export async function ProductCard({ id }: { id: string }) {
  // This is fine. db.product.findUnique runs on the server.
  const product = await db.product.findUnique({ where: { id } });

  if (!product) return null;

  return (
    <div>
      <h2>{product.name}</h2>
      <p>{product.price}</p>
      {/*
        Passing serializable props to a Client Component: OK.
        product.name is a string, product.id is a string.
      */}
      <ClientButton productId={product.id} label="Add to cart" />
    </div>
  );
}
// client-button.tsx
"use client";

import { useState } from "react";

interface Props {
  productId: string;
  label: string;
}

export function ClientButton({ productId, label }: Props) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    await fetch(`/api/cart`, {
      method: "POST",
      body: JSON.stringify({ productId }),
    });
    setLoading(false);
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? "Adding..." : label}
    </button>
  );
}

The boundary is at the import. ClientButton is a Client Component. Any props it receives from a Server Component parent cross the serialization boundary. productId and label are strings: fine. If you tried to pass the raw Prisma product object including its internal Prisma client references, you would get a serialization error at runtime.

What Lives Where

Before composition patterns, get clear on what belongs on each side.

Server Components can:

  • Access databases, file systems, and environment secrets directly
  • Import server-only packages (Node.js built-ins, ORM clients, etc.)
  • Use async/await at the component level for data fetching
  • Reduce the JavaScript shipped to the browser to zero for their own rendering logic

Server Components cannot:

  • Use React state (useState, useReducer)
  • Use lifecycle effects (useEffect, useLayoutEffect)
  • Use browser-only APIs (window, document, localStorage)
  • Register event handlers directly (onClick, onChange)
  • Use context consumers (though they can provide context through Client Component wrappers)

Client Components can:

  • Use all React hooks
  • Access browser APIs
  • Register event handlers
  • Render Server Components passed to them as children or other props

That last point is the one most engineers miss. A Client Component can render Server Components, but only if they arrive as props. It cannot import a Server Component directly.

// This will cause the imported module to be treated as a Client Component.
// Anything server-only inside UserProfile will error or be silently stripped.
"use client";
import { UserProfile } from "./user-profile"; // user-profile.tsx has no directive

export function Shell() {
  return <UserProfile />;
}
// Correct: pass the Server Component as children from a parent that is not
// a Client Component.
// layout.tsx (Server Component)
import { Shell } from "./shell"; // Client Component
import { UserProfile } from "./user-profile"; // Server Component

export default function Layout() {
  return (
    <Shell>
      <UserProfile />
    </Shell>
  );
}
// shell.tsx
"use client";

import { useState } from "react";

export function Shell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(true);

  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children}
    </div>
  );
}

children here is a React element tree that was already rendered on the server. The Client Component receives it as an opaque value and conditionally renders it. The Server Component’s output crosses the boundary as RSC payload, not as a function call.

The Donut Pattern

The composition pattern above has a name: the donut. The Client Component is the ring, the Server Component is the hole. The ring handles interactivity. The hole handles data.

This is not just a naming convention. It is the primary mechanism for keeping server-heavy logic out of the client bundle while still having interactive UI around it.

// page.tsx (Server Component)
import { getUser } from "@/lib/auth";
import { getActivityFeed } from "@/lib/db";
import { FeedShell } from "@/components/feed-shell"; // Client Component
import { ActivityItem } from "@/components/activity-item"; // Server Component

export default async function FeedPage() {
  const user = await getUser();
  const items = await getActivityFeed(user.id);

  return (
    // FeedShell owns scroll position, infinite scroll trigger, skeleton state.
    <FeedShell>
      {items.map((item) => (
        // ActivityItem renders on the server, zero client JS for each card.
        <ActivityItem key={item.id} item={item} />
      ))}
    </FeedShell>
  );
}
// feed-shell.tsx
"use client";

import { useRef } from "react";

export function FeedShell({ children }: { children: React.ReactNode }) {
  const containerRef = useRef<HTMLDivElement>(null);

  // Scroll tracking, intersection observer for infinite scroll, etc.
  // None of this runs inside ActivityItem. ActivityItem has no client bundle.

  return (
    <div ref={containerRef} className="feed-container">
      {children}
    </div>
  );
}

Every ActivityItem is rendered on the server and streamed as HTML plus RSC payload. The client receives no JavaScript for ActivityItem. The FeedShell JS is small because it only handles scroll behavior.

Data Fetching: Parallel by Default

Server Components can fetch data at any level of the tree. You do not need to lift state to a layout and thread it down. Each component fetches exactly what it needs.

// These three components can all be async and fetch independently.
// Next.js deduplicates fetch() calls with the same URL within a render pass.

async function UserAvatar({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then((r) => r.json());
  return <img src={user.avatarUrl} alt={user.name} />;
}

async function UserStats({ userId }: { userId: string }) {
  const stats = await fetch(`/api/users/${userId}/stats`).then((r) => r.json());
  return <div>{stats.postCount} posts</div>;
}

async function UserBio({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then((r) => r.json());
  return <p>{user.bio}</p>;
}

UserAvatar and UserBio both fetch the same endpoint. Next.js deduplicates these within the render via the fetch cache. You pay for one network request, not two. This lets you colocate data fetching with the component that renders it without worrying about N+1 fetch explosions within a single render pass.

For database queries, use React’s cache() function:

// lib/queries.ts
import { cache } from "react";
import { db } from "./db";

// Wrapped with cache(): calling this multiple times within one render
// with the same argument executes the query only once.
export const getUserById = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

The important shift here is that Client Components should not fetch data for rendering. They should receive data as props from Server Component parents, or use client-side fetching for mutations and real-time updates only. Using useEffect + fetch inside a Client Component to load the initial data that the page needs is a pattern that Server Components make obsolete.

Bundle Size Implications

Every Client Component and every module it imports ends up in the browser bundle. Server Components do not. This is the concrete bundle size story.

Consider a markdown rendering library like marked or a syntax highlighter like shiki. These can be 50-200kb minified.

// Without Server Components (old pages/ directory pattern)
// This entire library ships to the browser.
import { marked } from "marked";

export function ArticleBody({ markdown }: { markdown: string }) {
  return <div dangerouslySetInnerHTML={{ __html: marked(markdown) }} />;
}
// With Server Components (app/ directory)
// marked runs on the server. Zero bytes shipped to the browser for it.
import { marked } from "marked";

// No "use client" directive. This is a Server Component.
export async function ArticleBody({ markdown }: { markdown: string }) {
  const html = marked(markdown);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Same code, different location in the tree, completely different bundle outcome. The rule: if a component does not need hooks, event handlers, or browser APIs, keep it a Server Component.

Tradeoffs at a Glance

ConcernServer ComponentClient Component
Data fetchingDirect DB/API access, async/awaitClient fetch, SWR, React Query
Bundle sizeZero client JS for the componentAdded to client bundle
InteractivityNoneFull React hooks and event handlers
Browser APIsNot availableAvailable
Secret accessEnvironment secrets safeNo secrets, code is public
StreamingSupported via SuspenseSuspense for hydration only
ContextCannot consume (can provide via wrapper)Full context support
Re-renderingOn navigation/revalidationOn state/prop change

Production Mistakes and How to Avoid Them

Mistake 1: Putting too much in “use client” because it’s familiar. Engineers used to the pages directory tend to mark entire sections as Client Components to avoid errors. The result is that the bundle grows and the data fetching story reverts to useEffect. Treat Client Components as leaf nodes or thin interactive wrappers. Push data fetching up to Server Component parents.

Mistake 2: Importing server-only modules inside Client Components. This does not always error loudly. Sometimes Next.js silently strips the server code, sometimes it bundles things it should not. Use the server-only package to enforce the boundary:

// lib/db.ts
import "server-only"; // Throws at build time if imported in a Client Component.
import { PrismaClient } from "@prisma/client";

export const db = new PrismaClient();

Mistake 3: Passing non-serializable values as props across the boundary. The most common offenders are Date objects (use ISO strings), class instances (serialize to plain objects), and functions (pass Server Actions instead, or keep the callback on the client side).

// Wrong: Date is non-serializable as a prop crossing the boundary.
<ClientCard createdAt={product.createdAt} /> // createdAt: Date

// Correct: serialize to string before passing.
<ClientCard createdAt={product.createdAt.toISOString()} />

Mistake 4: Forgetting that context does not cross the boundary. React Context is a Client-only feature. Server Components cannot read context values. If you have authentication context, theme context, or locale context that you need in Server Components, read those values from cookies, headers, or session on the server directly. Do not try to thread them through context.

// server-component.tsx
import { cookies } from "next/headers";

export async function LocalizedPrice({ amount }: { amount: number }) {
  // Read locale from the request, not from context.
  const cookieStore = await cookies();
  const locale = cookieStore.get("locale")?.value ?? "en-US";

  return (
    <span>{new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(amount)}</span>
  );
}

Mistake 5: Using Suspense incorrectly with async Server Components. Wrap async Server Components in <Suspense> to stream them independently. If you do not, the entire page waits for the slowest component.

// page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./product-details"; // fast query
import { RelatedProducts } from "./related-products"; // slow query

export default function Page({ params }: { params: { id: string } }) {
  return (
    <main>
      {/* Renders immediately */}
      <ProductDetails id={params.id} />

      {/* Streams in when ready, does not block ProductDetails */}
      <Suspense fallback={<div>Loading related...</div>}>
        <RelatedProducts productId={params.id} />
      </Suspense>
    </main>
  );
}

The Decision Rule

When you create a component, ask one question first: does this component need to respond to user input or use browser state? If no, keep it a Server Component. If yes, make it a Client Component and push it as far toward the leaves of the tree as possible.

The goal is not to avoid Client Components. The goal is to keep the client bundle containing only the code that actually needs to run in the browser, and to keep data fetching as close to the server as possible. Server Components are the default. Client Components are the exception, used precisely where interactivity requires them.

Most of the confusion around this model comes from treating it as a new syntax rather than a different execution model. Once you see the rendering boundary as a serialization boundary, most of the edge cases resolve: non-serializable props error because they cannot cross the wire, context does not work in Server Components because context is a runtime mechanism that only exists in the client tree, and imports matter because the module graph determines what ends up in the bundle.

Build your components from the server inward. Use Client Components as the interactive boundary layer. The bundle size and performance characteristics follow naturally from that structure.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.