skills/react-server-components-boundary/SKILL.md
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
npx skillsauth add curiositech/windags-skills react-server-components-boundaryInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TL;DR:
'use client'is a module dependency boundary, not a render boundary. Mark the smallest possible leaf — everything it imports becomes client code. Use thechildrenslot to put Server Components inside Client Components. Props that cross must be serializable. Useserver-only/client-onlypackages to harden the seam.
| Symptom | Section | |---|---| | "Bundle exploded after I added one Client Component" | Leaf-pushing | | "Functions can't be passed directly to Client Components" | Serialization rules | | "Need state in this layout but can't make it Server" | Slot pattern | | "API key leaked into client bundle" | Environment poisoning | | "Third-party hook-using component breaks in RSC" | Wrapping third-party | | "Context provider broke build" | Provider placement |
'use client' actually isFrom react.dev/reference/rsc/use-client:
When a file marked with
'use client'is imported from a Server Component, compatible bundlers will treat the module import as a boundary between server-run and client-run code. As descendants of this boundary, modules are guaranteed to be bundled with and run on the client.
Two trees are in play and they don't move together:
| Tree | Boundary set by 'use client'? |
|---|---|
| Module dependency tree (imports) | YES — every transitive import becomes client code |
| Render tree (parent/child JSX) | NO — Server Components can render Client Components, and vice versa via children |
This distinction is the source of ~80% of RSC confusion. A component can be imported into both server and client modules and ship to both — it has no inherent identity until it's imported somewhere.
flowchart TD
A[Component needs to be added/changed] --> B{Uses hooks, event handlers, or browser APIs?}
B -->|No| C[Leave as Server Component]
B -->|Yes| D{Can you split the interactive bit out?}
D -->|Yes| E[Extract leaf Client Component<br/>parent stays Server]
D -->|No, whole subtree is interactive| F{Need to render Server children inside?}
F -->|Yes| G[Use children slot pattern<br/>parent is Server, wraps Client]
F -->|No| H["Add 'use client' at top<br/>verify all imports are client-safe"]
E --> I{Props from Server are serializable?}
G --> I
H --> I
I -->|No| J[Move data fetching out,<br/>pass plain values not functions/classes]
I -->|Yes| K[Ship]
Rule: Mark the smallest interactive leaf with 'use client'. Keep everything above it as Server Components.
The Next.js docs spell this out for layouts: a <Layout> with a <Logo> (static), nav links (static), and a <Search> bar (interactive) — only <Search> gets 'use client'. The layout stays Server and ships zero JS for itself.
// app/layout.tsx — Server Component (default)
import Search from './search' // Client
import Logo from './logo' // Server
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav><Logo /><Search /></nav>
<main>{children}</main>
</>
)
}
// app/search.tsx — Client Component
'use client'
import { useState } from 'react'
export default function Search() { /* ... */ }
Anti-pattern: the "use client at the top of every file" creep. A junior dev hits one useState error, slaps 'use client' on a layout file. Now the entire subtree — including a 300KB markdown renderer that only ran on the server — ships to the browser. RSC's bundle-size win evaporates.
Detect it: grep -rln "'use client'" app/ | wc -l — if it's growing faster than the count of components that genuinely need state/effects, you have client-component creep.
When you need a Client wrapper (modal, accordion, tab bar) but the content should be a Server Component (data fetching, secrets), pass the Server Component as children:
// app/ui/modal.tsx — Client (needs state)
'use client'
import { useState } from 'react'
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return open ? <div className="modal">{children}</div> : null
}
// app/page.tsx — Server (default)
import Modal from './ui/modal'
import Cart from './ui/cart' // Server Component, async, hits DB
export default function Page() {
return (
<Modal>
<Cart /> {/* Renders on the server, passed as JSX prop */}
</Modal>
)
}
From the Next.js docs:
All Server Components will be rendered on the server ahead of time, including those as props. The resulting RSC payload will contain references of where Client Components should be rendered within the component tree.
This pattern keeps Client Components pure UI shells — they don't import data layer code, they don't see secrets, they just render the children React gives them.
Props from Server → Client must be serializable. From react.dev:
| Allowed | Not allowed |
|---|---|
| Primitives (string, number, bigint, boolean, null, undefined) | Regular functions |
| Globally-registered Symbols (Symbol.for(...)) | Class instances |
| Iterables: String, Array, Map, Set, TypedArray, ArrayBuffer | Objects with null prototype |
| Plain objects (with serializable values) | Unregistered Symbols |
| Date | Closures over server-only state |
| Server Functions ('use server') | — |
| JSX elements (Server or Client) | — |
| Promise | — |
Worked example: passing an event handler.
// ❌ Doesn't work — onSubmit is a regular function
<EditForm onSubmit={(data) => savePost(data)} />
// ✅ Works — make it a Server Function
// app/actions.ts
'use server'
export async function savePost(data: FormData) { /* ... */ }
// app/page.tsx
import { savePost } from './actions'
<EditForm action={savePost} />
The same rule covers passing classes, Mongoose documents, Date objects with prototype gunk, etc. Strip to a plain object before it crosses.
Library uses hooks but ships without 'use client' (older libs especially). Importing it into a Server Component throws.
Fix: wrap once.
// app/carousel.tsx — your own thin wrapper
'use client'
export { Carousel as default } from 'acme-carousel'
// app/page.tsx — Server Component, now safe
import Carousel from './carousel'
export default function Page() {
return <Carousel /> // ✅ Crosses boundary correctly
}
The Next.js docs note for library authors: ship 'use client' at the entry point of any module that uses hooks/state, and configure the bundler to preserve the directive (some strip it). Examples: React Wrap Balancer, Vercel Analytics.
server-only / client-onlyA shared utility module is the silent foot-gun. Imagine lib/data.ts reads process.env.API_KEY and is imported by both a Server page and a Client form. From Next.js docs:
Only environment variables prefixed with
NEXT_PUBLIC_are included in the client bundle. If variables are not prefixed, Next.js replaces them with an empty string.
So the client gets getData() with Authorization: ''. It "fails gracefully" — meaning it silently 401s and you ship the bug.
Fix: hard-stop the import at build time with server-only:
// lib/data.ts
import 'server-only' // Build error if imported into a client module
export async function getData() {
const res = await fetch('https://api.example.com/data', {
headers: { authorization: process.env.API_KEY! },
})
return res.json()
}
The client-only package mirrors this for modules that use window, localStorage, etc., to prevent SSR explosions.
In Next.js these packages are optional — Next provides its own types and intercepts the imports — but installing them is the cheap insurance for non-Next RSC frameworks (Remix, future Waku, etc.).
createContext requires a Client Component. The naive fix wraps the entire <html>. Don't.
// app/layout.tsx — Server (root)
import ThemeProvider from './theme-provider' // Client
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
// app/theme-provider.tsx — Client
'use client'
import { createContext } from 'react'
export const ThemeContext = createContext({})
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
From the Next.js docs:
You should render providers as deep as possible in the tree — notice how
ThemeProvideronly wraps{children}instead of the entire<html>document. This makes it easier for Next.js to optimize the static parts of your Server Components.
| Anti-pattern | Detection | Fix |
|---|---|---|
| 'use client' on a layout/page that mostly does data fetching | git log -S "'use client'" and review what was added | Push to leaf; promote layout back to Server |
| Importing data-layer module (DB client, secrets) into a Client Component | grep -l "'use client'" \| xargs grep -l "from '@/lib/db'" | Move data fetching to Server parent, pass plain props |
| Passing class instances or functions as props | TypeScript Function or class types in 'use client' files' props | Convert to plain object / Server Function |
| Wrapping <html> with a context provider | <ThemeProvider> directly under <html> instead of <body>{children} | Push provider down to wrap {children} only |
| Forgetting 'use client' on a third-party hook lib | "useState only works in Client Components" runtime error | Create one-line wrapper file |
| Markdown/util import bloating client bundle | next build --analyze shows server-only deps in client chunk | Add import 'server-only' to that util |
| | Novice | Expert |
|---|---|---|
| First instinct on hook error | Add 'use client' to current file | Find the smallest leaf that uses the hook, mark that |
| Provider placement | Wraps <html> | Wraps {children} inside <body> |
| Sharing utils between server/client | Crosses fingers | Adds server-only / client-only guards |
| Sees serialization error | Searches Stack Overflow for 30 min | Recognizes "function/class crossing boundary," converts to Server Function or plain data |
| 3rd-party lib without 'use client' | Reports a bug to the lib | Writes 3-line wrapper, moves on |
Timeline test: if a teammate adds a Client Component this week, will the RSC bundle for the home page grow by 1KB or 100KB? An expert answer requires running next build before and after the change and diffing the route-level JS column.
A change touching the RSC boundary ships when:
next build route table).'use client' module imports a server-only module — verify by writing one bad import and confirming the build error.Function should fail typecheck.eslint-plugin-react-server-components) flags 'use client' files that import known server-only paths (@/lib/db, @/lib/secrets).<ThemeProvider> etc. wrap {children}, not <html>.'use client' in its dist, or a one-file local wrapper.react-component-architect or similar)nextjs-data-fetching or framework-specific skill)react-hydration-debugging)vite-build-optimizer or framework-specific toolingnextjs-server-actions-design'use client' directive reference — boundary semantics, serialization rules, caveatsserver-only and client-only packages'use client''use client'data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.
tools
Use when designing or fixing the retrieval side of a RAG system, choosing chunking strategy (fixed-size / recursive / semantic), implementing hybrid search (BM25 + dense) with RRF fusion, adding a cross-encoder reranker, evaluating with RAGAS, or running an index-freshness pipeline. Triggers: "RAG keeps citing the wrong doc", chunk size 512 tokens with overlap, RRF reciprocal rank fusion, dense+sparse hybrid, cross-encoder rerank top-5, RAGAS faithfulness / context precision, voyage-3-large vs text-embedding-3-large, daily / hourly reindex cadence. NOT for fine-tuning vs RAG decision (separate skill), agentic tool-use designs, vector DB operational tuning, or general LLM prompt engineering.