Frontend No. 062

React Server Components skill for Claude Code

Keep data on the server and props serializable. Gives Claude a precise RSC mental model so it stops sprinkling use client on whole pages or putting use server on components. It pushes interactivity to small leaves, maps database rows to minimal DTOs before they cross the boundary, treats every Server Function as a public endpoint with validation and authorization, and decodes the common serialization and hydration errors by cause.

  • namereact-server-components
  • size205 lines · ≈2,450 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Applies the React Server Components model correctly — where the server/client boundary goes, what can cross it, how Server Functions (server actions) are secured, and how to stream with Suspense and use(). Diagnoses common RSC errors such as functions passed to Client Components, non-serializable props, hooks in server files, and hydration mismatches. Use when writing "use client" or "use server" files, when an RSC serialization or hydration error appears, or when reviewing server actions for security.

Try asking

  • I get 'Functions cannot be passed directly to Client Components' — what is wrong?
  • Audit our server actions for missing auth checks and data leaks
  • Split this page so only the filter bar and cart button ship to the client

Use it when

  • Deciding where use client and use server directives belong
  • A serialization, hydration or hooks-in-server error appears
  • Reviewing server actions before launch
  • Adding streaming with Suspense and promises passed to client components

Skip it when

  • The issue is Next.js route caching or revalidation — use nextjs-app-router
  • The component is plain client-side React with no server rendering — use react-patterns

How to check the result

Build passes without RSC warnings, client chunks contain no secrets or server-only identifiers, and tests calling each server action with forged arguments are rejected.

The common mistake it prevents

Passing a whole ORM record as a prop, which leaks internal columns into the page payload even if the UI never displays them.

~/.claude/skills/react-server-components/SKILL.md

--- name: react-server-components description: Applies the React Server Components model correctly — where the server/client boundary goes, what can cross it, how Server Functions… license: MIT ---

RSC done right means: data and secrets stay on the server, the client bundle holds only interactive leaves, every value crossing the boundary is deliberately serializable, and every Server Function is treated as a public, untrusted HTTP endpoint.

Mental model (state it before debugging)

KindRunsCanCannot
Server Component (default in RSC frameworks)Server only, at build or request timeasync/await, read DB/files/secrets, import server-only libsuseState, useEffect, event handlers, browser APIs, context consumption
Client Component ("use client" module)Server for SSR, then hydrates in the browserState, effects, handlers, browser APIs, contextBe async; import server-only modules
Server Function ("use server")Server, invoked by the client via POSTMutate data, revalidate, redirectTrust its arguments

Key facts:

  • "use client" marks a module as an entry into the client bundle. Everything it imports is client code too. It does not mean “render only in the browser” — it still SSRs.
  • "use server" marks Server Functions, not Server Components. Never put it on a component file to “make it a server component”.
  • A Server Component can render a Client Component and pass it Server Components as children/props. A Client Component cannot import a Server Component, but it can render one it received as a prop.

Workflow

- [ ] 1. Draw the tree: mark which nodes need interactivity
- [ ] 2. Put "use client" on the smallest interactive leaves
- [ ] 3. Fetch data in Server Components; pass plain data down
- [ ] 4. Check every prop crossing the boundary is serializable and minimal
- [ ] 5. Write Server Functions with validation, authN, authZ, and minimal returns
- [ ] 6. Add Suspense boundaries where slow data should stream
- [ ] 7. Build and inspect the client bundle and RSC payload

Placing the boundary

// app/products/[id]/page.tsx — Server Component
export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await getProduct(id);              // DB access stays on the server
  return (
    <article>
      <h1>{product.name}</h1>
      <Markdown source={product.description} />      {/* heavy parser never shipped */}
      <AddToCart productId={product.id} price={product.priceCents} />
    </article>
  );
}

// components/AddToCart.tsx
"use client";
export function AddToCart({ productId, price }: { productId: string; price: number }) {
  const [qty, setQty] = useState(1);
  /* ... */
}

Rules:

  • If a page needs one interactive button, the page stays server and the button becomes a client component.
  • Context providers are client components. Wrap them in a providers.tsx with "use client" and render it in the layout around {children} — children stay server-rendered.
  • Third-party components that use hooks but lack "use client": re-export them from a local file that starts with "use client".
  • Add import "server-only" to modules that touch secrets, the DB, or internal APIs. Add import "client-only" to modules that touch window.

What can cross the boundary

Props from Server to Client Components, and arguments/returns of Server Functions, are serialized.

AllowedNot allowed
string, number, boolean, null, undefined, bigintOrdinary functions, event handlers
Plain objects and arrays of allowed valuesClass instances (ORM models, Decimal, custom errors)
Date, Map, Set, typed arrays, FormDataObjects with null prototype, symbols not from Symbol.for
Promises (resolved on the client with use())DOM nodes, streams of non-serializable values
JSX / Server Component elementsClosures over secrets (even if they would serialize)
Server Functions ("use server")

Convert at the boundary: map ORM rows to DTOs with only the fields the UI needs. This is both a serialization fix and a data-leak fix — whatever you pass is visible in the page source / RSC payload.

// lib/dto.ts
import "server-only";
export function toProductDTO(p: ProductRow) {
  return { id: p.id, name: p.name, priceCents: Number(p.price) * 100 }; // no costPrice, no supplierId
}

Server Functions are public endpoints

Anyone can call a Server Function with arbitrary arguments, regardless of which UI renders it. Every one must:

  1. Validate input with a schema (zod), never trust TypeScript types.
  2. Authenticate (who is calling) inside the function.
  3. Authorize (may they touch this record) inside the function — scope queries by owner/tenant.
  4. Return only what the client needs; never return full DB rows.
  5. Be idempotent or guarded when retried.
"use server";
import { z } from "zod";

const Input = z.object({ commentId: z.string().uuid() });

export async function deleteComment(raw: unknown) {
  const { commentId } = Input.parse(raw);
  const user = await requireUser();
  const result = await db.comment.deleteMany({ where: { id: commentId, authorId: user.id } });
  if (result.count === 0) return { ok: false as const, error: "Not found" };
  revalidateTag(`comments`, "max");          // framework-specific invalidation
  return { ok: true as const };
}
  • Inline Server Functions defined inside a Server Component capture closure values; frameworks encrypt them, but do not capture secrets or authorization decisions in closures — recompute on the server.
  • Prefer a Data Access Layer: one server-only module where every read checks the session, so components cannot accidentally bypass authorization.
  • Keep react, react-dom, react-server-dom-* and the framework patched. A critical RSC remote-code-execution flaw (CVE-2025-55182, December 2025) and follow-up advisories affected React 19 server packages; confirm installed versions are at or above the patched releases from the advisory with npm ls react-server-dom-webpack next.

Streaming with Suspense and use()

  • Wrap slow subtrees in <Suspense fallback={<Skeleton />}> so the shell flushes first. Place boundaries around independent slow regions, not one around the whole page.
  • Start promises in the Server Component and pass them unawaited to a Client Component that reads them with use():
// server
const reviewsPromise = getReviews(id);          // no await: starts now, streams later
return <Suspense fallback={<p>Loading reviews…</p>}><Reviews reviews={reviewsPromise} /></Suspense>;

// client
"use client";
export function Reviews({ reviews }: { reviews: Promise<Review[]> }) {
  const list = use(reviews);
  return <ul>{list.map((r) => <li key={r.id}>{r.text}</li>)}</ul>;
}
  • Never create the promise inside a Client Component’s render; it is recreated each render and suspends forever.
  • Pair Suspense with an error boundary so a rejected stream shows an error state, not a broken page.
  • Await sequentially only when one request depends on another; otherwise start all promises first, then await or pass them.

Error decoder

Error text (abridged)CauseFix
“Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with ‘use server’”Passing a callback prop from server to clientMove the handler into the client component, or make it a Server Function
“Only plain objects, and a few built-ins, can be passed to Client Components”Class instance / Decimal / ORM object as propMap to a DTO of primitives
“You’re importing a component that needs useState/useEffect…”Hook used in a module without "use client"Add "use client" to that leaf module
“async/await is not yet supported in Client Components”async function component inside a client moduleFetch in a server parent, or use use() with a passed promise
“createContext only works in Client Components”Provider defined in a server fileMove provider to a "use client" module
“Hydration failed because the server rendered HTML didn’t match the client”Date.now(), Math.random(), locale formatting, typeof window branches, invalid nesting (<div> in <p>)Render deterministic output; move browser-only values into an effect or a client-only child; fix HTML nesting
Secret appears in browser bundle or page sourceServer module imported by a client file, or secret passed as propimport "server-only"; pass derived values only

Gotchas

  • "use client" must be the first statement in the file (before imports).
  • Barrel files (index.ts re-exporting everything) can drag server modules into a client bundle. Import client components from their own files.
  • Server Components re-run on every request when dynamic — expensive work belongs in cached functions (framework-specific; see nextjs-app-router).
  • Do not read cookies or headers deep inside shared utilities without knowing it makes the whole route dynamic.
  • useFormStatus only works in a component rendered inside the <form>, not in the component that renders the form.

Verification

  1. Production build succeeds with no RSC warnings.
  2. Search the built client chunks for secrets and server-only identifiers: grep -r "DATABASE_URL\|sk_live" .next/static || echo clean (adapt the path to the framework’s output directory).
  3. View page source / the RSC payload in the network tab: only DTO fields appear.
  4. Call each Server Function with forged arguments (another user’s ID, malformed input) in a test; it must reject.
  5. Throttle the network in devtools and confirm Suspense fallbacks appear and content streams in independently.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc