| name | react-rsc |
| description | Use when working with React Server Components — choosing between Server and Client components, designing the server/client boundary, debugging hydration mismatches, fetching data on the server, using Server Actions, optimizing initial load, or migrating from a Pages Router or pure client-side React app to App Router + RSC. |
React Server Components — pragmatic 2025 playbook
This skill captures the production patterns that work in real codebases. It's prescriptive on purpose. Deviate only with a stated reason.
When to use this skill
- Picking between Server and Client component for a new piece of UI
- Designing or moving the server/client boundary
- Hitting a hydration mismatch
- Fetching data — async/await in components, caching, revalidation
- Building forms with Server Actions
- Migrating Pages Router → App Router
- Cutting JS bundle size on the critical path
Iron law
Server is the default. "use client" is the exception.
If you can't articulate why a component must run on the client (it uses state, effects, browser APIs, or event handlers), it's a Server Component. This single discipline is responsible for 80% of the perf gap between RSC-savvy and RSC-naive codebases.
The decision tree
Does it use useState, useEffect, useReducer, useContext (read), useRef, refs to DOM, event handlers, browser APIs (window, localStorage, etc.)?
├─ NO → Server Component (no directive)
└─ YES → Client Component ("use client" at the TOP)
└─ Push the boundary as far down the tree as possible.
Server children of Client components are still server-rendered.
Patterns
1. Push "use client" to the leaf
Wrong:
"use client";
export default function Page() {
return (
<div>
<Header /> {/* could be server */}
<ProductList /> {/* could be server */}
<CartButton /> {/* needs client */}
</div>
);
}
Right:
export default function Page() {
return (
<div>
<Header />
<ProductList />
<CartButton /> {/* this one is "use client" inside its file */}
</div>
);
}
2. Pass server data as props, not via useEffect
Wrong:
"use client";
function ProductList() {
const [data, setData] = useState();
useEffect(() => { fetch('/api/products').then(r => r.json()).then(setData); }, []);
}
Right:
export default async function Page() {
const products = await getProducts();
return <ProductList products={products} />;
}
3. Server Actions for forms — not API routes
Wrong: dedicated /api/contact POST + client fetch + custom optimistic logic.
Right:
"use server";
export async function sendMessage(prev: State, formData: FormData) {
const parsed = Schema.parse(Object.fromEntries(formData));
await db.insert(parsed);
revalidatePath('/inbox');
return { ok: true };
}
"use client";
import { useFormState } from 'react-dom';
import { sendMessage } from './actions';
export function Form() {
const [state, action] = useFormState(sendMessage, { ok: null });
return <form action={action}>...</form>;
}
4. Suspense + streaming for slow data
export default function Page() {
return (
<>
<Header /> {/* ships immediately */}
<Suspense fallback={<Skeleton />}>
<SlowProducts /> {/* streams when ready */}
</Suspense>
</>
);
}
async function SlowProducts() {
const products = await getProductsSlow();
return <ProductList products={products} />;
}
Anti-patterns
| Anti-pattern | Why it's wrong | Fix |
|---|
"use client" at the top of page.tsx | Disables RSC for the whole route | Move client logic into a small leaf component |
useEffect for initial data | Hydration delay, layout shift, no SSR | Server Component with await |
Forms via onSubmit + fetch('/api/...') | Reinvents Server Actions, no progressive enhancement | Server Action + useFormState |
| Importing a Server-only library into a Client component | Build error or runtime failure | Wrap usage in a Server Component, pass results as props |
dynamic(() => import(...), { ssr: false }) everywhere | Defeats SSR + RSC entirely | Use Suspense + Server Component instead, only ssr: false for genuinely client-only libs (e.g., charts that read window) |
Sharing modules between Server and Client without import 'server-only' | Server secrets can leak into client bundle | Use import 'server-only' at the top of any module that must never reach the client |
Hydration mismatch — debugging cheatsheet
When you see "Hydration failed because the initial UI does not match what was rendered on the server":
- First suspect:
Date.now(), Math.random(), or new Date() in render. Move to useEffect, or compute on server and pass as prop.
- Second suspect:
typeof window !== 'undefined' checks in render. That's a code smell — split into Client component or use useSyncExternalStore.
- Third suspect: browser extensions (Grammarly, dark mode extensions). Suppress with
suppressHydrationWarning only after eliminating 1 and 2.
- Fourth suspect: malformed HTML (e.g.,
<p> containing a <div>). The browser auto-corrects on parse; React doesn't. Run the page through W3C validator.
Verification checklist
When stuck
- Re-read this file from the top. Most "RSC won't cooperate" issues are decision-tree violations.
- Read the component tree as a server/client diagram. Color server components green, client red. Look for red branches that could be green.
- Look up the latest API behavior via Context7 — RSC semantics are still evolving (especially around caching,
unstable_cache, revalidateTag).
- Hand off to Cipher (Frontend Lead) if the decision is architectural beyond a single feature.