server-components-design — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited server-components-design (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
React Server Components (RSC) are a kind of component that runs only on the server, never ships to the browser as JavaScript, can be async, and can reach directly into server-side resources — databases, the file system, secrets, server-only environment variables. Their rendered output is serialized into a wire format (the RSC payload) and reconstituted into the client tree without any intermediating JSON API. Server and Client Components compose in a single tree with strictly one-way directionality: a Server Component may render (or import) a Client Component, but a Client Component may never import a Server Component — though it can receive one as a children/slot prop that its Server-Component parent already rendered. The two capability surfaces are disjoint: Server Components cannot use hooks, state, effects, event handlers, or browser APIs; Client Components cannot read databases, secrets, or server-only modules directly. The discipline this skill teaches is where to draw the boundary in the tree: push as much as possible to the server side and place 'use client' at the thinnest interactive leaf, so the surrounding layout and data reads stay server-only (zero bundle, no hydration, data baked into the payload). Three concerns run through every RSC review — execution locality (does this need server-only or browser-only resources?), data security (every prop crossing to a Client Component is serialized and shipped to the browser, so private fields must be filtered into minimal DTOs before they cross), and reveal/freshness (which reads block, which stream behind Suspense, and which are cached). Next.js App Router is the canonical implementation, but the primitive is the React RFC, so the discipline is framework-agnostic.
The discipline of designing React Server Components (RSC): what an RSC is for, what it can do that Client Components cannot, what it cannot do, how the server/client boundary shapes the component graph and the data-flow graph, how to keep private data from leaking across that boundary (Data Access Layer, Data Transfer Objects, the server-only package, and the taint APIs), how to fetch data without waterfalls (request memoization, parallel reads, the preload pattern), how RSC composes with Suspense to stream content from the server in chunks, how a framework's caching layer (Next.js 'use cache' / Partial Prerendering) reuses a Server Component's output, why Server Components remove the need for a separate API layer for read-path data, and the recurring design questions a reviewer asks of any RSC tree. Next.js App Router is the canonical implementation referenced throughout; the discipline applies to any RSC implementation (Remix RSC, Waku, Parcel RSC, hand-rolled RSC servers) since the underlying primitive is the React RFC, not a single framework.
RSC design also covers the data-freshness axis that modern App Router projects must make explicit: whether a Server Component read is uncached, request-cached, framework-cached, tagged for revalidation, or intentionally streamed behind Suspense. In current Next.js, Cache Components and the 'use cache' directive make caching a component/data design decision, not an incidental fetch option. This skill owns the RSC read-path placement question; server-actions-design owns the write-path mutation and revalidation trigger.
The original React component model collapsed two roles into one function: produce HTML for the initial render, and produce a virtual DOM update in response to client-side state. Server-Side Rendering pre-React-18 tried to make that single function run twice — once on the server to produce HTML, once on the client to produce the interactive tree — and pay for it with hydration: a full re-execution on the client to bind event handlers and reconstruct state.
Hydration has two costs the industry tolerated for a decade: every component must ship to the client (bundle size grows with the page), and every component re-runs on the client (CPU cost grows with the page). React Server Components separate the two roles into different kinds of component. A Server Component runs once, on the server, and produces a serialized output that the client uses directly — no shipping, no re-execution. A Client Component is what we used to call "a component": ships to the browser, runs on render and on every interaction.
The discipline of RSC design is to push as much of the tree as possible to the server side of the boundary, and to draw the line as close to the actual interactive leaves as possible. A button needs useState; the dashboard surrounding the button does not. A chart that responds to filter clicks is interactive; the page header above it is not. The win is real: bundle size shrinks toward "only the interactive parts," and the server has direct access to databases, file systems, and secrets without an intermediating API layer.
But the boundary is unforgiving — and it is a security boundary, not only a serialization one. A Server Component cannot use hooks. It cannot read state. It cannot attach event handlers. Anything that crosses to a Client Component must be serializable. The serializable set is wider than most people assume: strings, numbers, bigints, booleans, null, undefined, plain objects, arrays, Map, Set, Date, typed arrays / ArrayBuffer, Promises (React 19+), JSX / React elements, and globally-registered symbols (Symbol.for) all cross. What does not cross: functions (unless they are Server Functions marked 'use server'), class instances (any object that is an instance of a class other than the built-ins above, or a null-prototype object), event objects, and non-global symbols (Symbol('x')). So a Map or a Date is fine; a class instance or an onClick handler is not. And anything that crosses is shipped to the browser: a Server Component that passes its whole database row to a Client Component has just published every field of that row to the client, including the ones it filtered out of the UI. The discipline is to express the work in shapes that respect the serialization constraint, to filter private data into minimal Data Transfer Objects before it crosses, and to use Suspense boundaries to let the server-rendered tree stream piece by piece rather than blocking on the slowest piece.
The modern RSC design question has three axes, not one:
The old advice "fetch in a Server Component" is directionally right but incomplete. A review should also ask: what cache owns this data, what invalidates it, what fallback appears while it resolves, and what minimal DTO crosses to the client? A Server Component can access the database, but that does not mean it should pass raw database rows to Client Components or hide freshness behind an accidental framework default.
| Capability | Server Component | Client Component |
|---|---|---|
async / await at the component level | Yes — prefer it for data reads | No; unwrap a stable Promise with use() (React 19+), especially a Promise created in a Server Component and passed as a prop |
| Direct database queries, file reads, secret access | Yes | No — would leak to the browser |
Read environment variables (including PROCESS_SECRET-style) | Yes | No — only NEXT_PUBLIC_* vars are safe |
| Import server-only libraries (Postgres driver, file system) | Yes | No — bundling would fail (or worse, succeed and leak); guard with the server-only package |
Subscribe to browser events (onClick, onChange) | No | Yes |
Manage state (useState, useReducer) | No | Yes |
Side effects (useEffect) | No | Yes |
Read browser APIs (window, localStorage) | No | Yes |
| Render Client Components as children | Yes — boundary crosses here | Yes |
| Be rendered as a child of a Client Component | Only via the children / slot prop pattern | n/a |
| Receive props that are shipped to the browser | n/a — it sends them | Yes — every prop it receives is serialized to the client |
The boundary is not just about what's available; it's about what makes sense, and what is safe to send. A Server Component that renders the same output regardless of any client state is doing the right thing. A Server Component that wants to know "what tab did the user click on" is asking the wrong primitive — that's Client Component territory. A Server Component that hands a Client Component more data than that Client Component renders is leaking — see Data Security below.
Both kinds run on the server during the initial render, but in isolated module systems. A Client Component executes on the server only to produce prerender HTML, and must obey browser security assumptions: no secrets, noprocess.env(beyondNEXT_PUBLIC_*), no server-only modules. "Runs on the server" for a Client Component does not make it a safe place for privileged data.
A naive boundary draws the line at the page level: "this page needs interactivity, so the whole page is a Client Component." This loses most of the RSC benefit.
A disciplined boundary draws the line at the interactive leaves: the button that toggles a dropdown is a Client Component; the dropdown's structure, the surrounding layout, the data feeding it — all Server Components. The design goal has a name worth keeping: the thinnest client boundary — the 'use client' directive sits at the smallest leaf that genuinely needs state, effects, event handlers, refs, or browser APIs, with as much of the surrounding tree as possible staying on the server. Keep layouts, data reads, formatting, markdown rendering, and non-interactive structure on the server unless there is a concrete browser-only requirement.
The enabling pattern is passing Server Components as `children` (or any named slot prop) to Client Components:
// app/dashboard/page.tsx — Server Component
import { ServerData } from './ServerData'
import { ClientFilterBar } from './ClientFilterBar'
export default async function Dashboard() {
const data = await db.getDashboardData()
return (
<ClientFilterBar>
<ServerData data={data} />
</ClientFilterBar>
)
}
// ClientFilterBar.tsx — Client Component with state
'use client'
import { useState } from 'react'
export function ClientFilterBar({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState('all')
return (
<div>
<select onChange={e => setFilter(e.target.value)}>...</select>
{children} {/* Server Component renders here */}
</div>
)
}The children prop is content, not components-to-import. The Client Component receives the already-rendered React element from the server tree. This is the only way to put a Server Component "inside" a Client Component visually without violating the import rule. The same mechanism works for any prop typed as React.ReactNode — a Client Component can accept multiple Server-rendered slots (header, sidebar, content), not just children — which is how you keep a complex interactive shell (tabs, modals, providers, drawers, split panes, resizable splitters, filter bars) on the client while every slot it lays out stays on the server. Do not convert the whole shell subtree to client code merely because one wrapper is interactive.
When auditing or designing a tree, decide each component's kind by asking these questions in order. The first "yes" that forces the client wins — and it should force only that component to the client, not its ancestors (the thinnest-client-boundary rule above).
| Question about the component | If yes → | Reason |
|---|---|---|
Does it call useState / useReducer / useEffect / useRef, or any hook other than use()? | Client | Hooks need the client runtime; Server Components have none. |
Does it attach an event handler (onClick, onChange, onSubmit) the user triggers? | Client | Event handlers are not serializable and run in the browser. |
Does it read a browser API (window, document, localStorage, navigator)? | Client | These do not exist during server render; touching them crashes. |
Does it use a Client-only library (a charting lib that reads the DOM, an animation lib using requestAnimationFrame)? | Client | The library assumes a browser; wrap it in the smallest Client leaf. |
Does it query a database / read the file system / read a secret or non-NEXT_PUBLIC_* env var? | Server | Direct resource access is the Server Component's job; doing it on the client leaks credentials. |
| Does the data come only after user interaction or from a browser-only source (live polling, geolocation, post-click reads)? | Client (with a client data library — SWR / React Query — or an event-driven path) | Do not force a Server Component round-trip for live or post-interaction data; it has no server-render moment to attach to. |
| Does it just fetch-and-render data with no interactivity? | Server (default) | Zero bundle, no hydration, data baked into the payload. |
| Does it need interactive data but the fetch should stay on the server? | Split | Server Component starts the query and passes the unawaited Promise; a Client leaf unwraps it with use() (see the Promise-handoff section). |
The default answer is Server. A component is a Client Component only because one of the forcing rows above forced it — never "to be safe." When a row forces a leaf to the client, check whether you can push the 'use client' down (extract the interactive bit into its own small component) so the data-fetching and layout around it stay on the server.
A Client Component cannot import a Server Component. Not "should not" — cannot, because the bundler running on the client would have to bundle the Server Component, and Server Components are not allowed to ship.
But a Server Component can import a Client Component. The Client Component file is marked 'use client' at its top, the bundler treats it as a client-bundle entry point, and the Server Component renders a reference to it (in the RSC payload) that the client runtime resolves to the bundled component.
The directionality matters for tree design:
A tree that follows the first pattern naturally pushes work to the server. A tree that violates it (a Client Component near the root that needs to render a Server Component as a non-children child) cannot be expressed; you'll be forced to restructure.
Use "serializable" to mean React-serializable, not merely JSON-serializable — and treat this as a review reference card, not a license to pass rich objects. The current Server Component → Client Component prop allowlist includes:
string, number, bigint, boolean, undefined, null, and globally-registered symbols from Symbol.for(...);Map, Set, typed arrays, and ArrayBuffer;Date;'use server');The denylist is just as important: ordinary callback functions, class definitions, class instances other than the supported built-ins, objects with null prototypes, symbols created with Symbol(...), event objects, ORM entities, Request / Response objects, and custom objects that rely on methods or prototypes must not cross to Client Components.
Knowing the wire format can encode Map, Set, Date, typed arrays, Symbol.for, JSX, Server Functions, and Promises is not permission to pass rich domain objects. The RSC design default is still a small DTO shaped for the UI. If the value is privileged, persistence-shaped, or method-bearing, map it before it crosses.
The classic React data-fetching pattern:
useEffect, calls /api/dataRSC collapses steps 1–5 to:
The /api/data route does not need to exist. The Server Component reaches into the database, passes the result to a child component as a prop, and the prop arrives at the client as the rendered output — serialized in the RSC payload, never round-tripped through a JSON API.
The stronger rule is: do not call your own same-app Route Handler from a Server Component just to read data. A Server Component already runs on the server; calling fetch('/api/...') against your own app turns an in-process read into an HTTP round trip, may fail at build time when no server is listening, and duplicates the route-handler contract for no external consumer. Query the database / call the data layer directly instead.
When does an API route still make sense? When the consumer is real and external: a mobile app, a third-party integration, a server-to-server call, a separate backend team, a cross-language service, a public REST/GraphQL contract, or a deliberate zero-trust backend-for-frontend boundary. Existing large systems may continue to call hardened internal HTTP APIs from Server Components when those APIs are the security boundary. What RSC removes is the need to invent a private JSON endpoint solely so the page can fetch its own read data.
Do not use Server Actions as the read-path escape hatch either — see the anti-patterns table. Use Server Component data fetching for reads, Server Actions for writes, and Route Handlers for external or explicitly HTTP-shaped consumers.
Request-time inputs are async — and `params` / `searchParams` are untrusted. The four request-scoped inputs a Server Component can read — params, searchParams, cookies(), and headers() — are all async in the current App Router (Next.js 15 made searchParams and the cookies()/headers() accessors async; Next.js 16 removed synchronous params access entirely). You await each one:
import { cookies, headers } from 'next/headers'
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { slug } = await params
const { sort } = await searchParams
const session = (await cookies()).get('session') // async accessor
const lang = (await headers()).get('accept-language')
// ...
}They are async on purpose: synchronous request inputs would force the framework to resolve everything before it could start streaming the shell. Two design rules follow:
cookies()/headers()/searchParams high (in a root layout) taints the whole subtree as dynamic; reading it in the leaf that actually needs it keeps the rest of the page cacheable / prerenderable (this is the boundary the caching section below depends on).params and searchParams are user-controlled URL input (?isAdmin=true proves nothing), and headers() are equally forgeable by the caller. A signed/encrypted session cookie is the exception — but you still verify it server-side rather than trusting the raw value. Never gate authorization on a raw request input; re-verify the actual session and re-check resource ownership in the data layer (see Data Security).Three independent components in a tree may each need the current user. Naively that is three database round-trips per request. Two mechanisms collapse them:
cache() creates a distinct memoized function, invalidated per server request. (cache() is request-scoped memoization, not a cross-request cache — that is the framework caching layer below.)// app/lib/user.ts
import { cache } from 'react'
import 'server-only'
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session) return null
return db.user.findUnique({ where: { id: session.userId } })
})Data ownership. For reusable data like "current user," do not stand up a top-level provider just to avoid repeated reads. Server Components can call the same cached server-only function from each place that needs the data; prop-drilling a "current user" through every Server Component turns server data co-location into a fake global-provider problem. Reserve context providers for client interactivity and client state, not as a substitute for server read co-location.
The performance trap RSC makes easy is the waterfall: a parent awaits query A, then renders a child that awaits query B, so B cannot start until A finishes even though they are independent. Two fixes:
awaits open at once (the Suspense pattern below does this structurally), or `Promise.all([a(), b()])` when one component needs both.await) high in the tree so the request is in flight while other rendering proceeds; the component that finally needs it reads the already-resolved cache entry. Use the preload pattern to solve a measured waterfall, not pre-emptively everywhere.This is the most common and most dangerous RSC mistake, and it is invisible in the rendered UI. Every prop a Server Component passes to a Client Component is serialized into the RSC payload and shipped to the browser — including fields the Client Component never renders. A Server Component that does <Profile user={userRow} /> with a full database row has published the user's password hash, internal flags, and every other column to anyone who opens DevTools, even though the <Profile> only shows the name. The review question: "Could this prop be screenshotted from the browser devtools? If yes and that would be a leak, the Server Component is passing too much." Fix the data shape, not the component label.
Four guards, in roughly increasing strength:
return { name: user.name } instead of return user. This is the primary discipline; the rest are backstops.server-only module that (a) performs authorization, and (b) returns minimal DTOs. The DAL is the single place process.env and database drivers are imported, so secrets and queries cannot leak into render code. Re-check authorization (does this user own this resource?), not just authentication (is the user logged in?) — skipping this is the IDOR vulnerability class.import 'server-only' at the top of a module causes a build error if that module is ever imported into a Client Component. It turns "a refactor accidentally pulled secret-reading code into the client bundle" from a silent production leak into a failed build.experimental_taintObjectReference(reason, obj) and experimental_taintUniqueValue(reason, lifetime, value) mark an object or a specific value (a token, a key) as non-crossable; React throws if a tainted value reaches the client boundary. In Next.js, enable with experimental.taint: true. Tainting is a defense-in-depth backstop for simple mistakes, not a substitute for DTOs — a secure app layers all four.// data/user-dto.ts — the Data Access Layer
import 'server-only'
import { cache } from 'react'
import { getCurrentUser } from './auth'
export const getProfileDTO = cache(async (slug: string) => {
const [row] = await sql`SELECT * FROM users WHERE slug = ${slug}` // raw row, server-only
const viewer = await getCurrentUser() // authorization context
// Return ONLY the fields the UI may show this viewer:
return {
name: row.name,
phone: viewer.isAdmin || viewer.team === row.team ? row.phone : null,
}
})Functions and class instances are already blocked from crossing by default, and Next.js encrypts variables a Server Action closes over. Those are framework backstops — they reduce blast radius, they do not replace deliberate DTO design.
Operational security note. RSC support is framework-integrated, and the RSC / Server Functions stack has had critical security advisories. CVE-2025-55182 (CVSS 10.0) was an unauthenticated RCE caused by malicious requests being deserialized by vulnerable react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack releases — it affects any RSC app even without explicitly authored Server Functions. React patched the 19.0, 19.1, and 19.2 lines (19.0.1 / 19.1.2 / 19.2.1), and frameworks tracked downstream impact separately (for Next.js App Router, CVE-2025-66478). When reviewing or upgrading an RSC project, check the project's actual React, framework, and react-server-dom-* versions against the current advisory for that release line — do not rely on a generic "React 19 is installed" signal.
A Server Component that awaits a slow query blocks the entire HTML response. Suspense unblocks the pattern: wrap the slow component in <Suspense fallback={...}> and the server flushes the surrounding tree immediately, then streams in the slow component's output when it resolves.
// app/dashboard/page.tsx — Server Component
export default async function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<SkeletonChart />}>
<SlowChartData /> {/* awaits a 2-second query */}
</Suspense>
<Suspense fallback={<SkeletonTable />}>
<SlowTableData /> {/* awaits a 1.5-second query */}
</Suspense>
</>
)
}Both SlowChartData and SlowTableData start their queries in parallel (the Server Component renders both subtrees, and each await is a Promise that the renderer holds open). The chart and table fallback HTML ships immediately; the actual content streams in as each query resolves. Time-to-first-byte is bounded by the fastest non-Suspended path; time-to-interactive of each section is bounded by that section's own query.
Two design rules:
In Next.js App Router there are two practical Suspense surfaces:
Layouts deserve special scrutiny: a layout that performs uncached runtime reads can block navigation before the page-level loading.js gets a chance to show. Move the read into the page or a child Server Component, or wrap the runtime read in its own Suspense boundary.
React 19.2 also briefly batches server-rendered Suspense boundary reveals, so adjacent boundaries that resolve close together can reveal together. The design rule does not change: place boundaries by product meaning and waterfall risk, not by trying to micromanage every millisecond of reveal timing.
See suspense-patterns for the wider Suspense discipline — error boundaries, nested fallbacks, the relationship to React's transition APIs — and streaming-architecture for streaming as a general protocol concern.
use()Sometimes the interactive leaf itself needs the slow data — a Client Component that renders a chart needs the chart's data points. You do not have to await the query in the Server Component and block the whole subtree on it, and you must not fetch it from inside the Client Component (that reintroduces the useEffect waterfall). The pattern is to *start the query in the Server Component, pass the unawaited Promise as a prop, and unwrap it inside the Client Component with `use()`* under a Suspense boundary:
// page.tsx — Server Component: kick off the query, DON'T await it
import { Suspense } from 'react'
import { Chart } from './Chart'
export default function Page() {
const dataPromise = db.getChartData() // no await — a Promise crosses the boundary
return (
<Suspense fallback={<SkeletonChart />}>
<Chart dataPromise={dataPromise} />
</Suspense>
)
}
// Chart.tsx — Client Component: unwrap with use()
'use client'
import { use } from 'react'
export function Chart({ dataPromise }: { dataPromise: Promise<Point[]> }) {
const data = use(dataPromise) // suspends until resolved, then renders
return <InteractiveChart data={data} />
}A Promise is a serializable prop in React 19+, so it can cross the boundary; use() suspends the Client Component until it resolves, and the surrounding Suspense boundary streams the fallback first. Use this when the high-priority server content should render first, the lower-priority data can stream later, and the Client Component needs to own presentation or interactivity for that data. Two rules make it safe:
useEffect-fetching was supposed to avoid. Prefer server-created Promises, framework data sources, or a client data library with its own cache semantics.This keeps the data fetch on the server (one round-trip, streamed) while the component that consumes it stays interactive on the client — the read-path equivalent of the children/slot composition pattern, but for data rather than rendered markup.
use cache and Partial Prerendering (Next.js)React.cache() dedupes within a request. A cross-request cache — "this Server Component's output is the same for everyone for the next hour, don't re-render it" — is a framework concern layered on top of the RSC primitive. In Next.js 16 this is Cache Components, opt-in via cacheComponents: true. The model is a deliberate inversion of the older implicit caching: nothing is cached by default; you opt in with the 'use cache' directive (in the Cache Components model, even fetch is not cached by default).
revalidateTag / updateTag give on-demand invalidation (the latter triggered from the mutation path — see server-actions-design).// app/lib/products.ts
import { cacheTag } from 'next/cache'
import 'server-only'
export async function getProducts() {
'use cache'
cacheTag('products')
return db.product.findMany()
}The design rule maps cleanly onto the boundary discipline: a Server Component is cacheable when its output does not depend on the specific request (no cookies(), headers(), or searchParams read inside it). The moment it reads request-specific input it is dynamic — give it a Suspense boundary, leave it uncached, because uncached runtime reads outside Suspense can block prerendering and navigation (and may error in dev/build under Cache Components). This shifts review from "where is the fetch?" to "what is the cache key / lifetime / invalidation contract?" This is a framework extension, not part of the React RSC RFC; the which-runs-where discipline is portable, the caching directives are Next.js-specific (other RSC frameworks expose their own).
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
Whole page marked 'use client' to use one button | Loses all RSC benefit; ships entire page as client bundle | Move 'use client' to the leaf component that actually needs it |
| A fat Client Component boundary around a mostly static shell | Ships server-capable UI, data helpers, and formatting to the browser for one interactive control | Move 'use client' to the smallest interactive leaf; pass server-rendered content through slots |
| Passing a whole DB row / full record / ORM or domain object to a Client Component | Every field is serialized and shipped to the browser, leaking columns the UI never shows, and couples the UI to the persistence shape | Filter to a minimal DTO ({ name }) in a server-only Data Access Layer before it crosses |
| Treating "Server Component" as "safe to pass everything to the client" | The client can read serialized props and rendered output in devtools | Return minimal DTOs from server-only data functions; pass only fields the Client Component needs |
useEffect to fetch data on mount | Hydration delay + double-fetch + lost streaming | Fetch in a Server Component ancestor and pass data as prop |
Parent awaits query A, child then awaits independent query B | Sequential waterfall — B can't start until A resolves | Render as siblings (each in its own Suspense) or Promise.all; dedupe shared reads with React.cache() |
| Fetching current user high in the tree and prop-drilling through every Server Component | Turns server data co-location into a fake global-provider problem | Put the read behind a cached server-only function and call it where needed |
| Forgetting the cache / freshness contract | The UI may be stale, over-dynamic, or accidentally blocking static shells | Name the cache: uncached, React cache, framework 'use cache', tag / lifetime, and invalidation trigger |
Importing a Client Component that wraps children and trying to put a Server Component import inside | Bundler treats the Server Component as client code | Pass the Server Component as children / slot from a Server Component parent |
| Client Component imports a Server Component directly | The client bundle would have to include server-only code | Invert ownership: a Server Component imports the Client Component and passes server-rendered children / slots |
await fetch('/api/data') in a Server Component (calling your own same-app Route Handler) | Spending an HTTP round-trip for data you could query directly in-process; can fail at build | Query the database / call the DAL function directly; the API route is only for external consumers |
Using a Server Action ('use server') to read data for rendering | Server Actions are mutation-oriented and execute sequentially in a queue (one at a time per request), so using them for reads serializes data fetching and blocks parallelism | Read with a Server Component or a cached DAL function; reserve Server Actions for writes/mutations (see server-actions-design) |
Trusting searchParams/params/cookies/headers (?isAdmin=true) as authorization | They are user-controlled request input, not a trust signal, and reading them makes the subtree dynamic | Re-verify the real session server-side; check authorization (ownership), not just authentication |
| Treating "serializable" as JSON-only or as "anything structured" | Both are wrong: React supports bigint, Date, Map, Set, typed arrays, ArrayBuffer, Symbol.for, JSX, Server Functions, and Promises, while rejecting ordinary functions, classes, non-global symbols, event objects, and prototype-bearing instances | Use the current React-serializable allowlist, then still pass the smallest UI-shaped DTO |
Creating a Promise inside a Client Component render for use(promise) | Promise identity changes every render, defeating stable Suspense behavior | Create the Promise in a Server Component, or use a client data library cache |
Passing a function (onClick) from Server Component to Client Component | Not serializable across the boundary | Define the handler in the Client Component, pass primitive props instead |
Reading window or document in a Server Component | Will crash at render time | Move browser-API code to a Client Component or to a useEffect |
| One giant Suspense boundary at the page root | All slow content blocks together — no streaming benefit | Place boundaries near the slow data, one per independent slow section |
Uncached layout reads that rely on same-segment loading.js | The layout can block before the loading boundary is active | Move the read down or wrap the uncached subtree in explicit <Suspense> |
Caching a Server Component that reads cookies()/searchParams ('use cache') | Request-specific data gets frozen and served to the wrong user | Leave request-specific components dynamic + Suspense-wrapped; cache only request-independent output |
After applying this skill, verify:
'use client' appears at the leaf where interactivity actually starts, not higher in the tree — it is the thinnest boundary that satisfies the interaction requirement, with server-rendered slots used instead of moving whole shells client-side.'use client' only at its top level).Request/Response or ORM entities. (Map, Set, Date, typed arrays, bigint, Symbol.for, and Promises are serializable and may cross.)import 'server-only'.params, searchParams, cookies(), headers()) is trusted as a permission signal.params, searchParams, cookies(), headers()) are awaited and read as low in the tree as possible, so reading them does not taint an ancestor (a layout) as dynamic.cache, framework cache ('use cache' / tags / lifetime), or external API cache.Promise.all), shared reads are deduped with React.cache() or fetch auto-dedup, and no avoidable parent→child await waterfall remains.loading.js and component-level <Suspense> are chosen deliberately, and uncached layout reads do not accidentally block navigation.use(promise), preferably Promises created by a Server Component and wrapped in Suspense.useEffect fetches for data that could be fetched server-side.'use cache' / Cache Components), cached components are request-independent and request-specific data stays dynamic behind Suspense.react-server-dom-webpack / -parcel / -turbopack) and the framework that bundles them are on versions patched against CVE-2025-55182 (the unauthenticated Server Function deserialization RCE, patched in React 19.0.1 / 19.1.2 / 19.2.1) and the downstream framework advisory for the project's release line (e.g. Next.js CVE-2025-66478) — affects any RSC app even without explicitly authored Server Functions.'use client' and 'use server'. The directive semantics and the current serializable-props allowlist for the Server→Client boundary.cache. Request-scoped memoization for non-fetch reads (the preload + dedup pattern), with caveats.use. Unwrapping a Promise (or context) inside a Client Component — the Server→Client Promise-handoff pattern, Suspense/Error-boundary integration, and the preference for server-created stable Promises.react-server export condition, and the "no directive for Server Components" clarification.cacheSignal, partial-prerendering APIs, brief batching of server-rendered Suspense reveals, and Web Streams support.react-server-dom-* versions.experimental_taintObjectReference and experimental_taintUniqueValue. The defense-in-depth guards against leaking private data across the boundary.use API, sequential-vs-parallel fetch guidance, and React.cache sharing.server-only, taint, and the authentication-vs-authorization (IDOR) audit checklist.cookies() and headers(). The async request-scoped accessors that mark a component dynamic and must not be trusted as authorization facts.params / searchParams Promise shape, Client Component use() examples, and dynamic request-time behavior.use cache, and Version 16 Upgrade Guide. The opt-in caching model, Partial Prerendering, and current App Router direction.| Instead of this skill | Use | Why |
|---|---|---|
The mechanics of the 'use client' / 'use server' directive boundary itself (serialization rules, what crosses, RSC payload format) | client-server-boundary | client-server-boundary owns the boundary as a serialization mechanism; this skill owns the design discipline of where to draw the boundary in your component graph. |
| Hook discipline on the Client Component side (Rules of Hooks, useEffect, useMemo) | hooks-patterns | hooks-patterns covers what Client Components do correctly. Server Components cannot use hooks at all. |
| Choosing between SSR, SSG, ISR, and CSR rendering strategies | rendering-models | rendering-models owns the strategic rendering choice. RSC is one rendering mode among several. |
| Designing the Server Actions / form-mutation surface and re-authorization inside the action | server-actions-design | Server Actions are the write path; Server Components are the read path. They share infrastructure but have distinct design concerns. |
| Designing nested error boundaries, transition-driven fallbacks, and the broader Suspense orchestration | suspense-patterns | suspense-patterns owns the full Suspense discipline; this skill uses Suspense as one streaming primitive. |
| Streaming patterns broader than RSC (HTTP/2 push, SSE, WebSockets, AI streaming) | streaming-architecture | streaming-architecture covers streaming as a general protocol concern. RSC streaming is one application of that toolkit. |
<!-- skill-graph-context:start (generated — do not edit by hand) -->
Classification
frontend-engineeringtrueengineering/frontendWhen to use
should this be a Server Component or a Client Component, can I fetch data here, why can't I use useState in this file, how does data move from server to client, do I need an API route, why is the bundle so large, how do I stop private data leaking to the client, can I pass a Promise to a Client Component, why are params and searchParams promises, should this data be cached with use cache, why is loading.tsx not showingNot for
client-server-boundary: the serialization-and-directive mechanics of the boundary itself ('use client', what can cross, RSC payload format)rendering-models: the strategic decision among SSR, SSG, ISR, and CSRhooks-patternsserver-actions-design: the write path (mutations, 'use server' functions, form actions, re-authorization inside the action)Related skills
code-review, rendering-modelsclient-server-boundary, rendering-models, hooks-patterns, streaming-architecture, suspense-patterns, server-actions-designConcept
Keywords
React Server Components, RSC, Next.js App Router, async components, server-side data fetching, streaming RSC, server/client component tree, RSC payload, Data Access Layer DTO, Cache Components use cache<!-- skill-graph-context:end -->
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.