Overview

React Server Components (RSC) are the React 19 default in Next.js 15, Waku, and any framework on the new build pipeline. A server component renders on the server, ships zero JavaScript to the browser, and can fetch data directly. A client component renders in the browser and owns state, effects, and event handlers. The boundary between them is a thin contract enforced at build time. This page covers the rendering model, the wire format constraints, and the mistakes that break it. For the head-to-head selection rules, see server-vs-client-components.

Server by default, "use client" at the edges

In a React 19 framework, every file is a server component unless its first line is "use client". The directive marks a module (and its imports) as the client bundle’s entry. Push the directive as far down the tree as possible: a server page renders mostly server components and a few small client islands.

// app/page.tsx - server component, no directive.
import { ProductList } from "./product-list";
import { CartButton } from "./cart-button"; // a client component
export default async function Page() {
  const products = await db.product.findMany();
  return (
    <main>
      <ProductList products={products} />
      <CartButton />
    </main>
  );
}

Wrapping the whole page in "use client" defeats the model: every server component below it becomes a client component, and the bundle balloons. See nextjs for App Router specifics.

Pass serializable props across the boundary

The RSC payload travels from server to client as a serialized stream. Anything passed from a server component to a client component must be serializable: strings, numbers, booleans, plain objects, arrays, Date, Map, Set, BigInt, server-rendered JSX. Functions, class instances, symbols, and React refs do not cross.

// Wrong: function prop crosses the boundary.
<ClientFilter onChange={(v) => log(v)} />
 
// Right: pass a server action.
<ClientFilter onChange={logAction} />

A server action is a function marked "use server". The framework replaces it with an RPC reference on the wire; the client invokes it like a normal function. This is how forms and mutations cross the boundary safely.

Fetch data inside the component that renders it

Colocate the fetch with the component. Server components are async functions; await a database call, a service, or a typed fetch inline. React deduplicates fetch calls within a single render pass, so two components asking for the same resource hit the network once.

async function UserCard({ id }: { id: string }) {
  const user = await db.user.findUnique({ where: { id } });
  return <article>{user.name}</article>;
}

No useEffect, no useState, no client-side waterfall. Prop-drill the result only when a child genuinely needs it; otherwise let each component fetch what it owns. The dedupe cache makes the cost flat.

Stream with Suspense, never block the shell

Wrap any server component that fetches in a <Suspense> boundary so React can stream the page shell first and patch in the slow region when its data resolves. The HTML arrives in chunks; the browser parses the shell, paints, and waits.

<Suspense fallback={<Skeleton />}>
  <RecentActivity userId={id} />
</Suspense>

See react-suspense for boundary design and SSR streaming. Without a boundary, a slow await blocks the entire response.

Keep client components small and leaf-ward

A client component cannot import a server component directly, but it can accept server-rendered JSX as children or as a named slot. Use this to keep interactive shells thin.

// client component
"use client";
export function Tabs({ tabs, children }: { tabs: Tab[]; children: ReactNode }) {
  const [active, setActive] = useState(0);
  return (
    <div>
      <nav>{/* tab buttons */}</nav>
      <section>{children}</section>
    </div>
  );
}
 
// server component renders the server-side content as children
<Tabs tabs={t}>{await renderActivePanel()}</Tabs>

The Tabs shell owns the interactive state; the heavy content stays on the server.

Common mistakes that cross the wire

These are the failures that show up most often in RSC code review.

  • Passing a non-serializable prop (function, class, Map of class instances) from server to client. The build fails; the error is loud, fix the contract.
  • Importing a server-only module (fs, pg, a secret) from a client component. Mark the module import "server-only" so the violation fails at build time, not at runtime.
  • Reading cookies, headers, or env vars in a client component. Read them in a server component, pass the values down.
  • Using useState or useEffect in a file without "use client". The framework rejects it.
  • Wrapping a server data fetch in a client component “for loading state.” Move the fetch up to the server component and wrap the parent in Suspense instead.