---
title: "React: Server Components"
slug: "react-server-components"
category: "frontend"
tags: ["frontend", "react", "rsc", "server-components", "nextjs"]
status: "stable"
last_updated: 2026-05-14
summary: "React 19 server components: the rendering model, the use client boundary, serializable props, async fetching, and the mistakes that cross the wire."
related: ["[[frontend/react]]", "[[frontend/nextjs]]", "[[frontend/react-hooks]]", "[[frontend/react-suspense]]", "[[frontend/react-forms]]", "[[coding/typescript]]"]
---

## Overview

React Server Components (RSC) are the React 19 default in Next.js, 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 [[comparisons/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.

```tsx
// 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 [[frontend/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.

```tsx
// Wrong: function prop crosses the boundary.
<ClientFilter onChange={(v) => log(v)} />

// Right: pass a server action.
<ClientFilter onChange={logAction} />
```

A [[frontend/nextjs-server-actions|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.

```tsx
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.

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

See [[frontend/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.

```tsx
// 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.

## Related

- [[frontend/react]]
- [[frontend/nextjs]]
- [[frontend/react-hooks]]
- [[frontend/react-suspense]]
- [[frontend/react-forms]]
- [[frontend/react-state-management]]
- [[coding/typescript]]
