All posts
React & Next.js··10 min read

React Server Components: A Mental Model That Finally Makes Sense

Server Components confuse even experienced React devs. Here is the mental model that makes the server/client boundary obvious, and the mistakes to avoid.

By

On this page

I have watched senior engineers, people who can explain React's reconciler from memory, freeze the moment they hit a "use client" error in Next.js. They know hooks, they know suspense, they know memoization cold. And they still drop a "use client" at the top of a layout to "make it work" and ship 300KB of JavaScript that did not need to exist.

The problem is not skill. It is that everyone tries to map Server Components onto something they already know: SSR, getServerSideProps, or "components that run on the server." None of those analogies hold, and they actively mislead. So let me give you the model I actually use when I architect an App Router tree, the one that makes the boundary obvious instead of mysterious.

The one sentence that unlocks everything

Server Components run once, during the render, on the server, and never ship their code to the browser. Client Components are the only thing that becomes interactive JavaScript.

That is it. Server Components are not "components that can also run on the server." They run exclusively on the server (or at build time). The browser never sees their source. What it receives is the output of that render: a serialized description of UI, plus the JavaScript for whatever Client Components were embedded inside it.

Stop thinking "server vs client rendering." Start thinking "compile-away code vs shipped code." Everything in a Server Component is compile-away. The instant you cross into a Client Component, you are shipping that code and everything it imports to the browser.

This reframes the entire architecture question. The job is not "where does this run." The job is "how do I keep the shipped bundle as small as the interactivity actually requires."

Default to server, opt into client

In the Next.js 16 App Router, every component is a Server Component unless a file in its import chain declares "use client". There is no "use server" needed to make a component a Server Component, that directive means something else entirely (it marks Server Actions).

Here is a Server Component fetching data. No useEffect, no loading state machine, no SWR. Just async/await directly in the component body.

// app/dashboard/page.tsx  — Server Component (no directive needed)
import { db } from "@/lib/db";
import { RevenueChart } from "./revenue-chart";
 
export default async function DashboardPage({
  searchParams,
}: {
  searchParams: Promise<{ range?: string }>;
}) {
  const { range = "30d" } = await searchParams;
 
  // Runs on the server. The DB client, the SQL, the secrets — none of it
  // is ever sent to the browser.
  const rows = await db.query(
    "SELECT day, total FROM revenue WHERE range = $1 ORDER BY day",
    [range],
  );
 
  return (
    <section>
      <h1>Revenue</h1>
      {/* RevenueChart is the only interactive island below */}
      <RevenueChart data={rows} />
    </section>
  );
}

Notice what is not here. There is no /api/revenue route. There is no client fetch, no waterfall of useEffect(() => fetch(...)), no isLoading flag. The component awaits the database directly. Your data layer lives in the component that needs the data, and the network round trip the browser would have made simply does not happen.

This is the single biggest practical win and the one people under-use because the old habits run deep. If you find yourself writing a fetch to your own API from a Client Component, stop and ask whether a Server Component can read that data directly.

The boundary, and how it propagates

"use client" is a boundary marker, not a per-component flag. When you put it at the top of a file, you are saying: "this module, and everything it imports, is part of the client bundle."

The critical, frequently-misunderstood rule: the boundary propagates down through imports, not down through the rendered tree.

// app/dashboard/revenue-chart.tsx
"use client";
 
import { useState } from "react";
import { Chart } from "some-charting-lib"; // pulled into the client bundle
 
export function RevenueChart({ data }: { data: { day: string; total: number }[] }) {
  const [hovered, setHovered] = useState<string | null>(null);
  return (
    <Chart
      data={data}
      onPointHover={(p) => setHovered(p.day)}
      highlight={hovered}
    />
  );
}

Once revenue-chart.tsx is marked "use client", every module it imports is a Client Component too, transitively. You do not re-declare "use client" in Chart. The marker establishes the entry point; the bundler walks the imports from there.

What you put above that boundary stays on the server. DashboardPage imports RevenueChart, but importing a Client Component from a Server Component does not pull the Server Component into the bundle. The boundary only flows one way: from the marked file into its imports.

What can cross the boundary: serialization

When a Server Component renders a Client Component, the props have to travel from server to browser. That means they get serialized — turned into a wire format the React client runtime can rehydrate. So props can only be things that serialize.

This is the rule that bites people:

Crosses the boundaryDoes NOT cross
Strings, numbers, booleans, nullFunctions, callbacks, event handlers
Plain objects and arraysClass instances (e.g. Date survives, a custom class does not)
Date, Map, Set, BigInt, typed arraysClosures over server-only state
Promises (yes, you can pass a Promise)JSX from a Client Component's own imports
React elements (as children / slots)Symbols, functions on objects
Server Actions (functions marked "use server")Arbitrary methods

You cannot pass onClick={handleClick} from a Server Component down into a Client Component. There is no handleClick in the browser — it only existed during the server render, which is long over. The exception is a Server Action, which is a function the framework replaces with a callable reference to a server endpoint.

Server vs Client capabilities at a glance

This table is the one I keep in my head when deciding where a piece of logic belongs.

CapabilityServer ComponentClient Component
async/await in the component bodyYesNo
Direct DB / filesystem / secret accessYesNever (it would leak)
useState, useEffect, useReducerNoYes
Event handlers (onClick, onChange)NoYes
Browser APIs (window, localStorage)NoYes
Ships JavaScript to the browserNoYes
Can import a Server Component directlyYesNo (only via children)
Re-renders on interactionNo (renders once)Yes
Access to React ContextNo (provider must be client)Yes

The pattern everyone misses: children as a slot

Here is the question that trips up even experienced teams: "I need a Client Component wrapper — say, a tab panel with state — but the content inside it is a Server Component that hits the database. The wrapper can't import a Server Component. So I have to make the whole subtree client, right?"

No. This is exactly what the children slot pattern solves.

A Client Component cannot import a Server Component. But it can accept one as a prop. Because children is passed in from the parent — which is a Server Component — the children are rendered on the server before they are handed to the client wrapper. The wrapper just receives finished UI and slots it in.

// app/dashboard/collapsible-panel.tsx
"use client";
 
import { useState, type ReactNode } from "react";
 
export function CollapsiblePanel({
  title,
  children, // <-- already-rendered server UI arrives here
}: {
  title: string;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen((v) => !v)}>{title}</button>
      {open && <div>{children}</div>}
    </div>
  );
}

And the composition, in a Server Component:

// app/dashboard/page.tsx (excerpt) — still a Server Component
import { CollapsiblePanel } from "./collapsible-panel";
import { db } from "@/lib/db";
 
async function RecentOrders() {
  const orders = await db.query("SELECT id, total FROM orders LIMIT 10");
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id}>#{o.id} — ${o.total}</li>
      ))}
    </ul>
  );
}
 
export default function Section() {
  return (
    <CollapsiblePanel title="Recent orders">
      {/* RecentOrders renders on the server; its DB code never ships */}
      <RecentOrders />
    </CollapsiblePanel>
  );
}

CollapsiblePanel owns the interactivity (a few hundred bytes of state logic). RecentOrders stays fully on the server, hitting the database, shipping zero JavaScript. The client wrapper holds server-rendered children without ever importing them. This is how you keep interactive shells thin while the data-heavy guts stay server-side.

Internalize this and most "I have to make everything client" problems evaporate. Providers work the same way: a ThemeProvider is a Client Component, but you render it high in layout.tsx and pass server-rendered children straight through.

The four mistakes I see in every code review

1. "use client" too high in the tree. Someone needs a useState for a dropdown, so they mark the whole page client. Now the page, every child, and every library those children import all ship to the browser. Push the boundary down to the smallest component that genuinely needs interactivity. Leaves, not roots.

2. Importing server-only modules into client code. A Client Component imports a file that touches your database connection or reads process.env.DATABASE_URL. Best case it errors; worst case a secret leaks into the bundle. Guard those modules:

// lib/db.ts
import "server-only"; // build fails if this is imported into a client bundle
 
import { Pool } from "pg";
 
export const db = new Pool({ connectionString: process.env.DATABASE_URL });

The server-only package turns "accidentally bundled a secret" from a silent production incident into a build-time error. Use it on every module that touches secrets, the database, or the filesystem.

3. Prop-drilling handlers across the boundary. Trying to pass onSubmit from a Server Component into a Client form. Functions do not serialize. Either move the interactive piece into a Client Component that defines its own handler, or use a Server Action — a "use server" function you can pass and call from the client.

4. Faking server data fetching with client fetch. Writing an API route plus a useEffect fetch to load data that a Server Component could read directly. That is a self-inflicted network waterfall. If the data is needed for the initial render and does not depend on browser state, fetch it in a Server Component.

A decision framework

When you are about to add "use client", run through this:

  • Does this specific element need state, effects, event handlers, or a browser API? If no, it stays a Server Component. Full stop.
  • If yes, what is the smallest component that needs it? Mark that one, not its parent. A like-button does not require a client page.
  • Does the interactive wrapper need to display server-rendered content? Use children as a slot. Do not import the server content into the client file.
  • Does this component touch secrets, the DB, or the filesystem? It must be a Server Component, and the module it lives in should import "server-only".
  • Am I about to write a fetch to my own backend? Check whether a Server Component can read that source directly first.

The mental model collapses to a single instinct: server by default, client at the leaves, and let children carry server UI through the interactive shells. Once that becomes automatic, the boundary stops being a source of errors and starts being the thing that keeps your bundles honest. Your users download the JavaScript their interactions actually require — and nothing else.