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.
- name
react-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.
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)
| Kind | Runs | Can | Cannot |
|---|---|---|---|
| Server Component (default in RSC frameworks) | Server only, at build or request time | async/await, read DB/files/secrets, import server-only libs | useState, useEffect, event handlers, browser APIs, context consumption |
Client Component ("use client" module) | Server for SSR, then hydrates in the browser | State, effects, handlers, browser APIs, context | Be async; import server-only modules |
Server Function ("use server") | Server, invoked by the client via POST | Mutate data, revalidate, redirect | Trust 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.tsxwith"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. Addimport "client-only"to modules that touchwindow.
What can cross the boundary
Props from Server to Client Components, and arguments/returns of Server Functions, are serialized.
| Allowed | Not allowed |
|---|---|
| string, number, boolean, null, undefined, bigint | Ordinary functions, event handlers |
| Plain objects and arrays of allowed values | Class instances (ORM models, Decimal, custom errors) |
Date, Map, Set, typed arrays, FormData | Objects 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 elements | Closures 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:
- Validate input with a schema (
zod), never trust TypeScript types. - Authenticate (who is calling) inside the function.
- Authorize (may they touch this record) inside the function — scope queries by owner/tenant.
- Return only what the client needs; never return full DB rows.
- 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-onlymodule 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 withnpm 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) | Cause | Fix |
|---|---|---|
| “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 client | Move 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 prop | Map 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 module | Fetch in a server parent, or use use() with a passed promise |
| “createContext only works in Client Components” | Provider defined in a server file | Move 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 source | Server module imported by a client file, or secret passed as prop | import "server-only"; pass derived values only |
Gotchas
"use client"must be the first statement in the file (before imports).- Barrel files (
index.tsre-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.
useFormStatusonly works in a component rendered inside the<form>, not in the component that renders the form.
Verification
- Production build succeeds with no RSC warnings.
- 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). - View page source / the RSC payload in the network tab: only DTO fields appear.
- Call each Server Function with forged arguments (another user’s ID, malformed input) in a test; it must reject.
- 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.