Guide
React Server Components explained
Harbor Commerce's product catalog page shipped 420 KB of gzipped
JavaScript before shoppers saw a single SKU. The page fetched inventory
from Postgres in a client-side useEffect, parsed 12,000
product rows in the browser, and hydrated a deeply nested component tree
— all while Core Web Vitals flagged LCP at 4.2 seconds on mobile.
The refactor moved the catalog tree to React Server
Components (RSC): data fetching runs on the server during
render, only interactive islands (filters, add-to-cart) ship as Client
Components, and the HTML payload streams to the browser. Client
JavaScript dropped 68%; LCP improved to 2.8 seconds. RSC is React's
model for components that execute exclusively on the server, never
download their logic to the client, and compose with traditional Client
Components through a serialization boundary. This guide covers the
server vs client split, async data fetching, what can cross the
boundary, composition patterns, Next.js App Router integration,
pairing with
Suspense
and
error boundaries,
a Harbor Commerce worked example, a component-type decision table,
common pitfalls, and a production checklist.
What Server Components are
In the classic React model, every component in your tree eventually becomes client-side JavaScript. Server Components invert that default: components in a Server Component file run only on the server. They can read databases, filesystems, and internal APIs directly without exposing credentials to the browser. Their output is a serialized React tree (the “Flight” payload) that the client reconciles into DOM — but the component function itself never ships.
Client Components — marked with 'use client'
at the top of the file — behave like traditional React: they
hydrate, use hooks (useState, useEffect),
attach event listeners, and access browser APIs. The App Router
defaults to Server Components; you opt into client behavior explicitly.
What Server Components can do
awaitdatabase queries and HTTP fetches directly in the component body- Import server-only modules (ORM clients, secrets, filesystem utilities)
- Render large lists without bloating the client bundle
- Access backend resources with zero extra API round-trips
What they cannot do
- Use React state, effects, or refs
- Attach event handlers (
onClick,onChange) - Access
window,document, orlocalStorage - Use browser-only APIs without delegating to a Client Component child
Async Server Components and data fetching
Server Components can be async functions — a pattern
impossible in Client Components. Instead of fetching in
useEffect and juggling loading states on the client, you
await data at render time on the server:
// app/products/page.tsx — Server Component (default)
export default async function ProductsPage() {
const products = await db.product.findMany({
where: { active: true },
take: 50,
});
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
);
}
The server renders HTML (or streams it) with data already embedded. No waterfall of client fetch → spin → render. Pair async components with Suspense boundaries to stream partial UI: the shell arrives immediately while slower queries resolve in nested segments. For mutations and client-side cache invalidation, keep using TanStack Query inside Client Components — RSC handles the initial read path; client libraries handle optimistic updates and refetch.
The serialization boundary
When a Server Component renders a Client Component, props must be
serializable: strings, numbers, booleans, plain
objects, arrays, null, undefined, and
certain built-ins (Dates serialize to strings). You cannot pass
functions, class instances, Symbols, or non-plain objects like
Map or database row objects with methods.
A common mistake: fetching a Prisma model in a Server Component and passing the full ORM object to a Client child. Strip to plain JSON first:
// Server Component
const row = await db.product.findUnique({ where: { id } });
return <AddToCartButton product={{ id: row.id, name: row.name, price: row.price }} />;
// Client Component ('use client')
export function AddToCartButton({ product }: { product: { id: string; name: string; price: number } }) {
// useState, onClick, etc.
}
The boundary also flows up through composition: a Server
Component can import and render a Client Component, but a Client
Component cannot import a Server Component. To nest server-rendered
content inside client UI, pass Server Components as
children or slots from a parent Server Component.
Composition patterns
Server parent, client islands
The recommended layout: Server Component page fetches data and renders static structure; small Client Component islands handle interactivity. A product page might be entirely server-rendered except for the quantity picker and “Add to cart” button.
Children as server slots
A Client Component wrapper can accept Server Component children because the parent Server Component creates both:
// page.tsx (Server)
export default async function Page() {
const data = await fetchStats();
return (
<ClientTabs>
<ServerOverview stats={data} />
<ServerDetails stats={data} />
</ClientTabs>
);
}
ClientTabs manages tab state; each tab panel is a Server
Component that never ships its logic to the client.
Shared context limitations
React Context created in a Client Component does not automatically reach Server Component children passed as slots — context providers must wrap Client descendants. Plan provider placement carefully when mixing RSC and client state.
Next.js App Router integration
Next.js 13+ App Router is the primary production framework for RSC today. Conventions that matter:
app/routes default to Server Components. Add'use client'only where needed.- Layouts and pages can be async. Shared layouts fetch once per navigation segment.
loading.tsxand Suspense stream fallback UI while async segments resolve.error.tsxcatches errors in that segment (see error boundaries guide).- Route handlers (
route.ts) remain the right place for webhooks and non-UI APIs; don't conflate them with RSC data fetching. - Server Actions (
'use server') handle form mutations from Client Components without a separate REST endpoint.
For deeper App Router patterns, see Next.js fundamentals. Static or marketing pages that need zero interactivity can be pure Server Components with no client JS at all.
Worked example: Harbor Commerce catalog refactor
Harbor Commerce's catalog had three problems: a 420 KB client bundle, a client-side fetch waterfall, and inventory credentials exposed through a public REST endpoint the SPA called. The refactor split the tree as follows:
app/catalog/page.tsx(Server, async) — queries Postgres via Prisma with server-only connection string; renders category sidebar and product grid.ProductCard.tsx(Server) — renders image, title, price, stock badge from plain props. No hooks.CatalogFilters.tsx(Client) — category checkboxes and price slider; reads/writes URL search params viauseSearchParams.AddToCartButton.tsx(Client) — cart state, optimistic UI, Server Action for persistence.
The public inventory API was removed; the database is reachable only from the Node server process. Filters trigger navigation (URL change) which re-runs the Server Component with new query params — no client-side re-fetch library needed for the grid. Results: 68% smaller client bundle, 1.4 s LCP improvement, zero credential exposure, and simpler mental model (server reads, client interacts).
Server vs Client decision table
| Need | Component type |
|---|---|
| Direct database / filesystem access | Server Component |
| Static markup, no interactivity | Server Component |
| Large list rendering (blog archive, catalog) | Server Component |
| onClick, onChange, form events | Client Component |
| useState, useEffect, useRef | Client Component |
| Browser APIs (localStorage, geolocation) | Client Component |
| Third-party widgets requiring window | Client Component (often dynamic import with ssr: false) |
| Real-time subscriptions (WebSocket) | Client Component |
| Data fetch on initial page load | Server Component (preferred) or Client + TanStack Query |
| Optimistic mutation + cache invalidation | Client Component + Server Action or API |
Common pitfalls
- Marking the entire app
'use client'. Defeats RSC benefits; push the directive to the smallest interactive leaf. - Passing non-serializable props. Functions, class instances, and ORM objects crash or silently fail at the boundary.
- Importing server-only code into Client Components. Bundlers may leak secrets; use separate
server-onlypackage imports in Server Components. - Fetching in both server and client. Duplicate requests and hydration mismatches; pick one source of truth per data slice.
- Using hooks in Server Components. Build error or confusing runtime failures; split into a Client child.
- Expecting Context from Client to reach Server children. Provider must wrap client descendants only.
- Ignoring streaming. One giant async page blocks TTFB; nest Suspense for progressive rendering.
- Over-using Server Actions for reads. Actions are for mutations; reads belong in Server Components or route handlers.
Production checklist
- Audit component tree: default to Server; add
'use client'only for interactivity. - Move initial data fetches from
useEffectto async Server Components. - Strip ORM/API responses to plain objects before passing to Client children.
- Mark server-only modules with the
server-onlynpm package. - Add Suspense boundaries around slow async segments for streaming.
- Pair route-level
error.tsxwith granular error boundaries for client islands. - Measure client bundle before/after with
@next/bundle-analyzer. - Verify LCP and TTFB in Lighthouse after refactor.
- Keep TanStack Query (or similar) in Client Components for mutation/refetch paths.
- Document which routes are server-only vs client-hydrated for the team.
Key takeaways
- Server Components run only on the server — zero client JS for their logic.
- Async await in components eliminates client fetch waterfalls on initial load.
- Serialization boundaries require plain props when crossing into Client Components.
- Compose server shells with client islands for the best bundle and UX trade-off.
- Next.js App Router is the production home for RSC today; default server, opt into client.
Related reading
- React fundamentals explained — components, hooks, and the client model RSC extends
- Next.js fundamentals explained — App Router, layouts, and full-stack patterns
- React Suspense explained — streaming and loading boundaries for async RSC
- Core Web Vitals explained — LCP and TTFB metrics RSC refactors improve