Back/Module F-3 Server Components vs Client Components — First Contact
Module F-3·24 min read

The network boundary in plain terms, what "use client" actually costs, why Server Components are the default, and the five mistakes every engineer makes the first week.

F-3 — Server Components vs Client Components: First Contact

Who this is for: Developers who have the App Router file system model from F-2 but haven't internalised the Server Component mental model yet. This module is the biggest conceptual leap in the entire course. Take it slowly. The confusion most engineers feel with the App Router traces back to skimming or misunderstanding this module.


The Shift That Changes Everything

React has always been a client-side library. You import it, it runs in the browser, it manages the DOM. Every mental model you built around hooks, effects, event handlers, browser APIs — all of it was built around the assumption that your components run in the browser.

React Server Components break that assumption at the root.

Starting with Next.js 13 App Router and React 18, a component can now be an async function that runs on the server, fetches data directly from a database or API, renders HTML, and sends the result to the browser — without ever shipping a single byte of its code to the client.

This is not "SSR." Server-Side Rendering has existed since the early days of Next.js — the Pages Router did SSR via getServerSideProps. The difference is architectural:

  • Old SSR model: The entire page runs on the server (page-level function → props → component tree renders). The component code also ships to the browser and runs again during hydration.
  • RSC model: Individual components can be server-only. Their code never reaches the browser. They don't hydrate. They exist as rendered output only.

This distinction is why React Server Components are a genuine paradigm shift rather than a performance tweak.


Two Worlds, One Tree

In the App Router, your component tree is split across a boundary. On one side: Server Components. On the other: Client Components. Both render React, but they have different rules, different capabilities, and different costs.

Server Components:

  • Run on the server at request time (or build time for static pages)
  • Can be async — can await database queries, API calls, file reads
  • Cannot use hooks (useState, useEffect, useRef, etc.)
  • Cannot attach event handlers (onClick, onChange, etc.)
  • Cannot access browser APIs (window, document, localStorage)
  • Zero JavaScript sent to the browser — they don't hydrate

Client Components:

  • Run in the browser (and also on the server during SSR for the initial HTML)
  • Can use hooks — useState, useEffect, useRef, useContext, all of them
  • Can attach event handlers
  • Can access browser APIs
  • Their code ships to the JavaScript bundle — they hydrate in the browser

The key insight: Server Components are the default. When you create a file in the App Router and don't add any directive, it's a Server Component. You opt into the client side explicitly.


The 'use client' Directive

Adding 'use client' at the top of a file marks it as a Client Component. This creates a boundary — a point where the server-rendered tree hands off to the client-rendered tree.

tsx
// Without 'use client' — this is a Server Component // ✅ Can await database queries // ✅ Code never ships to browser // ❌ Cannot use useState export default async function ProductList() { const products = await db.products.findMany(); return ( <ul> {products.map(p => <li key={p.id}>{p.name}</li>)} </ul> ); }
tsx
// With 'use client' — this is a Client Component 'use client'; import { useState } from 'react'; // ✅ Can use useState, useEffect, onClick // ✅ Works in the browser // ❌ Cannot be async, cannot directly query the database // ⚠️ This component's code ships to the browser bundle export default function AddToCartButton({ productId }: { productId: string }) { const [loading, setLoading] = useState(false); async function handleClick() { setLoading(true); await addToCart(productId); setLoading(false); } return ( <button onClick={handleClick} disabled={loading}> {loading ? 'Adding...' : 'Add to Cart'} </button> ); }

The 'use client' directive does not mean "this only runs in the browser." It means "this is a Client Component." React will still server-render it for the initial HTML — your button will appear in the HTML document before JavaScript loads. But it will also hydrate in the browser, and its code will be in the JavaScript bundle.


The Boundary Rule: Client Components Cannot Import Server Components

This is the rule that catches every engineer off guard at least once:

A Client Component cannot import a Server Component.

If you do this:

tsx
'use client'; import ServerComponent from './ServerComponent'; // ❌ This breaks the RSC model

The ServerComponent would be pulled into the client bundle — it would run in the browser just like any other imported module. The "server-only" contract is violated.

But you can pass Server Components as props to Client Components:

tsx
// app/page.tsx (Server Component) import ClientLayout from '@/components/ClientLayout'; import ServerSidebar from '@/components/ServerSidebar'; export default function Page() { return ( // ✅ Passing a Server Component as children to a Client Component <ClientLayout sidebar={<ServerSidebar />}> <h1>Content</h1> </ClientLayout> ); }
tsx
// components/ClientLayout.tsx (Client Component) 'use client'; export default function ClientLayout({ children, sidebar, }: { children: React.ReactNode; sidebar: React.ReactNode; }) { const [sidebarOpen, setSidebarOpen] = useState(true); return ( <div> {sidebarOpen && sidebar} {/* ServerSidebar renders here — still server-only */} <main>{children}</main> </div> ); }

ServerSidebar is composed as a prop (or as children), not imported. It was already rendered on the server before being passed to ClientLayout. The Client Component receives it as opaque React node — it can render it, but it doesn't control it or ship its code.

This pattern — passing Server Components through Client Component slots — is how you keep expensive server logic isolated while still having interactive wrappers around it.


What Does 'use client' Actually Cost?

Every time you add 'use client', you're making a decision: the component and everything it imports will be in the JavaScript bundle shipped to the browser.

This isn't a reason to avoid Client Components — interactivity requires them. But it is a reason to think about where you place the boundary.

Consider a product page with a mostly static layout and one interactive element:

ProductPage (Server)
├── ProductImages (Server)    ← static, no bundle cost
├── ProductDescription (Server) ← static, no bundle cost
├── ProductReviews (Server)   ← static, no bundle cost
└── AddToCartSection (Client) ← interactive, ~4KB to bundle
    └── AddToCartButton (Client)
    └── QuantitySelector (Client)

The boundary is pushed as deep as possible. Only the interactive section and its children are Client Components. The rest of the tree renders on the server, produces HTML, and ships zero JavaScript.

Compare this to the mistake pattern:

ProductPage ('use client' at the top)
├── ProductImages ← now a Client Component, costs bundle space
├── ProductDescription ← now a Client Component, costs bundle space
├── ProductReviews ← now a Client Component, costs bundle space
└── AddToCartSection ← Client Component
    └── AddToCartButton
    └── QuantitySelector

Both produce the same visible output. The first version ships ~4KB of JavaScript. The second ships the entire product page tree as JavaScript. For an e-commerce site with 100,000 product pages, that's the difference between instant load times and frustrated users.

The rule: push 'use client' as deep into the tree as possible. The boundary should be right at the first point where interactivity is genuinely needed.


The Five Mistakes

These are the errors every engineer makes in their first week with Server Components. Listed here so you can avoid them instead of discovering them via cryptic build errors.

Mistake 1: Adding 'use client' to every component "just in case"

Some engineers learn that Server Components can't use hooks, panic, and put 'use client' everywhere. This works but defeats the entire purpose of the App Router. You pay the bundle cost, you lose the ability to fetch data directly, and you've effectively built a Pages Router app with extra syntax.

The fix: only add 'use client' when the component genuinely needs hooks, event handlers, or browser APIs.

Mistake 2: Trying to use hooks in a Server Component

tsx
// ❌ This will throw a build error export default async function ProductPage() { const [liked, setLiked] = useState(false); // useState is not a Server Component API // ... }

If you need state, the component needs to be a Client Component, or the state needs to live inside a child Client Component. Server Components are stateless by definition — they run once, produce output, and that's it.

Mistake 3: Passing non-serializable props across the boundary

Server Components can pass data to Client Components as props. But those props must be serializable — they have to travel across the network as JSON-like data.

tsx
// ❌ Functions are not serializable <ClientButton onClick={() => console.log('hi')} /> // ✅ Pass primitive data, call server actions for mutations <ClientButton productId={product.id} />

Event handlers cannot be passed from Server Components to Client Components as props. If you need an event handler, either the component defining it must be a Client Component, or you use a Server Action (covered in P-2).

Mistake 4: Using context across the Server/Client boundary

React context (createContext, useContext) only works in Client Components. You cannot create context in a Server Component and consume it in a Client Component, or vice versa.

tsx
// ❌ Server Components can't use context export default async function Page() { const theme = useContext(ThemeContext); // useContext is a hook — Server Components can't use hooks }

Data that needs to flow to Client Components either comes through props (for explicit, component-level passing) or through a Client Component context provider that wraps the relevant subtree.

Mistake 5: Importing server-only code in Client Components

tsx
// ❌ This pulls database code into the browser bundle 'use client'; import { db } from '@/lib/db'; // db is a server-only module (Prisma, drizzle, etc.)

This will likely fail at runtime in the browser (Prisma doesn't run in the browser), but it might fail silently in some configurations and produce a bloated bundle in others. The server-only package exists precisely for this:

ts
// lib/db.ts import 'server-only'; // ← throws a build error if this file is ever imported in a Client Component import { PrismaClient } from '@prisma/client'; export const db = new PrismaClient();

Adding import 'server-only' to any module that should never reach the browser turns a silent runtime failure into a loud build-time error. Use it for database clients, secret-reading utilities, and any file that handles credentials.


The Rendering Model in Practice

To make this concrete, here's what happens when a user navigates to /products/42 in a Next.js App Router application:

  1. Server receives the request. Next.js begins rendering app/products/[id]/page.tsx on the server.

  2. Server Components run. The page component (a Server Component) runs async functions, queries the database, and renders its tree — including any Server Component children.

  3. Client Component boundaries are encountered. When the server encounters a Client Component (anything marked 'use client'), it renders its initial HTML output on the server (this is the SSR pass). The component's JavaScript is scheduled to be sent to the browser.

  4. The response is streamed. HTML is streamed to the browser incrementally. The <Suspense> boundaries you've set up (either explicitly or via loading.tsx) determine the streaming shape — content above Suspense boundaries arrives first, content inside async Suspense boundaries streams in as it resolves.

  5. Browser receives HTML. The user sees content immediately — no blank screen, no JavaScript-boot wait.

  6. Hydration. The browser downloads the JavaScript for Client Components and "hydrates" them — attaching event handlers, initialising state, making them interactive. This happens in the background while the user is already reading content.

  7. Subsequent navigations. React Router takes over in the browser. Further navigations don't do a full page request — the client fetches only the RSC payload (a special JSON-like format) for the new page segment and re-renders the affected parts of the tree.

Server Components only run in steps 1–4. They don't participate in step 6 (hydration) at all.


The server-only and client-only Packages

Two small packages from the Next.js ecosystem that belong in every production codebase:

bash
npm install server-only client-only

import 'server-only' in any module that must never reach the browser. import 'client-only' in any module that must never run on the server (a module that accesses window, for example, where the server would throw a window is not defined error).

These packages contain nothing — they only throw at build time if imported in the wrong environment. They're guardrails, not logic. Put them in:

  • Database clients
  • Functions that read process.env secrets
  • Any utility that calls server-only Node.js APIs

And for client-only:

  • Browser analytics initialisation
  • Modules that reference window, document, or navigator
  • Any third-party SDK that explicitly requires a browser environment

Context and State Patterns for Server Components

Server Components being stateless doesn't mean your application is stateless. It means state lives where it belongs: in Client Components, in the URL (via searchParams), or in the server (via cookies or session).

The pattern you'll use constantly:

tsx
// app/products/page.tsx (Server Component) // URL state: /products?category=shoes&sort=price interface PageProps { searchParams: Promise<{ category?: string; sort?: string }>; } export default async function ProductsPage({ searchParams }: PageProps) { const { category, sort } = await searchParams; const products = await db.products.findMany({ where: category ? { category } : {}, orderBy: sort ? { [sort]: 'asc' } : { createdAt: 'desc' }, }); return ( <> <FilterBar /> {/* Client Component — manages URL state via useRouter/useSearchParams */} <ProductGrid products={products} /> </> ); }

FilterBar is a Client Component that reads useSearchParams() and calls router.push() when the user changes filters. The URL changes, Next.js re-runs the Server Component with new searchParams, and the page re-renders with filtered data. No useState needed in the server component. No prop drilling of filter values. The URL is the state.

This URL-as-state pattern is idiomatic App Router code. Get comfortable with it early.


When to Choose Server vs Client

Use this as your decision tree:

Server Component when:

  • You're fetching data from a database, API, or filesystem
  • The component has no interactivity (no event handlers, no dynamic state)
  • You're rendering static or semi-static content
  • You're handling sensitive data (credentials, secrets) that must never reach the browser
  • Bundle size matters and the component has no browser-side reason to exist

Client Component when:

  • You need useState, useReducer, or useContext
  • You need useEffect, useRef, or other lifecycle hooks
  • You're attaching event handlers (onClick, onChange, onSubmit)
  • You need browser APIs (window, document, localStorage, navigator)
  • You're using a third-party library that doesn't support Server Components (most UI libraries, animation libraries, etc.)

When in doubt: start as a Server Component. If you hit a compiler error or runtime error related to hooks or browser APIs, that's the signal to add 'use client'. Don't pre-emptively add it.


Where We Go From Here

F-4 covers data fetching in the App Router — the practical mechanics of fetching data in Server Components, how fetch() behaves differently in Next.js, and how to use Suspense to stream multiple data sources independently.

The concepts from this module are the prerequisite for everything in the Practitioner phase. When you're in P-6 making decisions about the 4-tier caching architecture, you're fundamentally deciding which parts of your data fetching live in Server Components and how aggressively to cache them. When you're in P-2 writing Server Actions, you're writing server-side functions that can be called from Client Components — the exact boundary you've been learning about here.

That boundary is the skeleton of the entire App Router. Every pattern we cover assumes you've internalised it.

Discussion