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

  • await database 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, or localStorage
  • 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.tsx and Suspense stream fallback UI while async segments resolve.
  • error.tsx catches 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:

  1. app/catalog/page.tsx (Server, async) — queries Postgres via Prisma with server-only connection string; renders category sidebar and product grid.
  2. ProductCard.tsx (Server) — renders image, title, price, stock badge from plain props. No hooks.
  3. CatalogFilters.tsx (Client) — category checkboxes and price slider; reads/writes URL search params via useSearchParams.
  4. 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-only package 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 useEffect to async Server Components.
  • Strip ORM/API responses to plain objects before passing to Client children.
  • Mark server-only modules with the server-only npm package.
  • Add Suspense boundaries around slow async segments for streaming.
  • Pair route-level error.tsx with 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