Week 18: Next.js — Server vs. Client Components

Everything so far has been a client-side single-page app: one HTML shell, all rendering done in the browser. Next.js changes where components actually run — some render on the server and never ship their code to the browser at all. This is the single biggest mental shift in the whole course, and this week builds it from the ground up.

Module 11 of 17 Week 18 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what Next.js adds on top of the React you already know
  • Navigate the App Router's file-based routing conventions
  • Decide correctly between a server component and a client component

1. What Next.js Adds

Everything through Week 17 was a client-side rendered (CSR) app: Vite ships one near-empty HTML file, and the browser downloads and runs JavaScript that builds the entire page from scratch. That means a real, if brief, blank moment before anything appears, and search engines/link previews see very little without executing JavaScript first.

Next.js is a React framework that adds server-side rendering, file-based routing, and a build/deployment pipeline on top of React itself — it doesn't replace anything you've learned; hooks, components, props and state all work exactly the same. What changes is where code runs and how routing and data fetching are wired up.

terminal
npx create-next-app@latest my-app
cd my-app
npm run dev
Turbopack by default

Since Next.js 16, create-next-app scaffolds a project using Turbopack — Vercel's Rust-based bundler — as the default for both next dev and next build, replacing webpack. You won't notice a syntax difference in anything this course covers; the practical effect is a dev server that starts and hot-reloads noticeably faster than Vite ever needed you to think about, and a production build pipeline covered in Week 22.

2. Project Setup & File-Based Routing

The App Router (Next.js's current, recommended routing system) maps your file structure directly to URLs — no route configuration array like Week 6's createBrowserRouter. A folder is a URL segment; specific filenames inside it have special meaning.

app/ directory structure
app/
├── layout.tsx          # root layout -- wraps every page
├── page.tsx             # matches "/"
├── products/
│   ├── page.tsx          # matches "/products"
│   └── [productId]/
│       └── page.tsx      # matches "/products/:productId" -- dynamic segment
└── about/
    └── page.tsx          # matches "/about"

page.tsx defines the UI for that route; layout.tsx wraps every page beneath it — directly analogous to Week 6's <Outlet />-based nested layouts, except the nesting is expressed as folder nesting rather than a route config array. [productId] — square brackets around a folder name — is the App Router's equivalent of Week 6's :productId dynamic segment.

app/products/[productId]/page.tsx
export default async function ProductDetailPage({
  params,
}: {
  params: Promise<{ productId: string }>;
}) {
  const { productId } = await params;
  return <h1>Product #{productId}</h1>;
}
params is a Promise, not a plain object

This trips up almost everyone coming from an older Next.js tutorial: params (and searchParams) must be awaited before you can read a segment off them. It's an async value on purpose — it lets Next.js start rendering a route's static shell before dynamic segment data is even resolved, part of the same streaming model the App Router uses everywhere. A page reading params.productId directly, without await, throws.

3. Server Components by Default

This is the biggest conceptual shift: in the App Router, every component is a Server Component by default. A Server Component renders entirely on the server — its code, and any large libraries it imports, never gets sent to the browser at all. The browser receives only the resulting HTML (and a lightweight description React uses to reconcile it) — not JavaScript for that component.

app/products/page.tsx — a Server Component
async function getProducts() {
  const res = await fetch('https://api.example.com/products');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts(); // runs on the SERVER, not in the browser

  return (
    <ul>
      {products.map((p: Product) => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Notice ProductsPage is an async function component — that's only valid for Server Components, and it means you can await data directly in the component body, no useEffect or useQuery needed for this kind of fetch. The generated HTML arrives already containing the product list; there's no client-side loading spinner for this data at all, because no client-side fetch ever happens.

Server Components can't use hooks

useState, useEffect, useContext — none of Weeks 2–13's hooks work in a Server Component, because it never runs in a browser at all and has no concept of "re-rendering in response to state." Any interactivity needs a Client Component (Section 4).

Does getProducts run on every request?

Yes, by default — the fetch above runs fresh every time this page is requested. Older Next.js tutorials assumed fetch was cached indefinitely unless told otherwise; that's no longer the default model. Week 21 covers Next.js 16's Cache Components and the "use cache" directive, which is how you now explicitly opt specific data into being cached and reused across requests.

4. The 'use client' Directive

A component needs to be a Client Component — hydrated and running in the browser, exactly like everything from Weeks 1–17 — the moment it needs interactivity: useState, event handlers, effects, browser-only APIs. Opt in with a 'use client' directive at the top of the file.

app/products/AddToCartButton.tsx
'use client'; // must be the very first line -- opts this whole module into the client bundle

import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: number }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => setAdded(true)}>
      {added ? 'Added ✓' : 'Add to cart'}
    </button>
  );
}
app/products/[productId]/page.tsx — mixing both
import { AddToCartButton } from '../AddToCartButton';

export default async function ProductDetailPage({ params }: { params: Promise<{ productId: string }> }) {
  const { productId } = await params;
  const product = await getProduct(productId); // runs on the server

  return (
    <div>
      <h1>{product.name}</h1>               {/* rendered on the server, zero JS shipped for this part */}
      <AddToCartButton productId={product.id} /> {/* hydrated, interactive, runs in the browser */}
    </div>
  );
}

This is the pattern almost every real Next.js page follows: a Server Component handles data fetching and static content, and imports small, focused Client Components only for the specific pieces that need interactivity — the button, not the whole page. Everything outside those client islands ships zero JavaScript.

5. Choosing Server vs. Client

Default to a Server Component; add 'use client' only when you hit a genuine requirement for it:

  • Stay a Server Component when: fetching data directly, reading environment secrets/API keys, rendering static or server-computed content, or including large dependencies you don't want shipped to the browser at all.
  • Switch to a Client Component when: using useState/useEffect/any hook, attaching event handlers (onClick, onChange), using browser-only APIs (localStorage, window), or using a third-party library that itself depends on hooks or browser APIs.
Push 'use client' as far down the tree as possible

Marking your whole page 'use client' because one small button needs state defeats the entire benefit — that reverts the whole tree to the CSR model from Weeks 1–17. Extract the interactive piece into its own small client component (like AddToCartButton above) and keep everything around it as a Server Component.

6. Hands-on Exercise

Hands-on

Port Week 6's product catalog to the Next.js App Router

Rebuild the same feature you already know, this time drawing the server/client line deliberately — then push on that boundary until it breaks, and fix it correctly.

Part 1 — The port:

  1. Scaffold a new Next.js app with create-next-app and set up app/products/page.tsx and app/products/[productId]/page.tsx, following Section 2's file-based routing.
  2. Fetch your product data directly inside each async page component (simulate an API with a delay, as in earlier weeks) — no useQuery, no client-side fetch, for the base list and detail views. Remember: params is a Promiseawait it before reading productId.
  3. Extract exactly one interactive piece — a "favorite" toggle button on each product card — into its own 'use client' component, and confirm (via a comment or a quick build-output check) that everything else on the page stays a Server Component.
  4. Add a shared app/layout.tsx with a nav bar, confirming it wraps every route without needing to be repeated per page.
  5. In your browser's DevTools, view the page source (not the rendered DOM, the actual HTML response) for the products list and confirm the product names are present in the raw HTML — proof the content was server-rendered, not just client-rendered after the fact.
Hint

"View Page Source" (not "Inspect Element") shows the actual HTML the server sent, before any client-side JavaScript runs — that's the honest test of whether something was truly server-rendered, since the rendered DOM in DevTools always looks complete either way.

Part 2 — Break the boundary on purpose, then filter server-side:

  1. In the products list page (a Server Component), define a plain function like function handleFavorite(id: number) { console.log(id); } and try passing it straight into your Client Component as an onFavorite prop, without wrapping it in an API route or Server Action. Read the actual error Next.js gives you at build or runtime — it's telling you a function can't cross the server→client serialization boundary as a prop.
  2. Fix it correctly: move that logic entirely into the Client Component itself (or, if it genuinely needs to run on the server, pass data down and trigger a Server Action from inside the client component instead — a preview of Week 20).
  3. Replace the category filter with a server-side one: read searchParams (also a Promise in Next.js 16) in app/products/page.tsx, and filter the fetched product list before it's ever sent to the client, instead of filtering an already-fetched array in a client component.
  4. Confirm in your Network tab that clicking a category link performs a real navigation request that returns different HTML per category — not a client-side re-filter of data already in memory.
Hint

The serialization-boundary error is one of the most common early Next.js mistakes, and it's a genuinely useful one to see once on purpose — you'll recognize it instantly in a real project instead of being confused by it under deadline pressure.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What is fundamentally different about where a Server Component's code runs, compared to everything built in Weeks 1–17?

It renders entirely on the server, and its code — along with anything it imports — is never sent to the browser at all. Everything through Week 17 ran as client-side JavaScript in the browser; a Server Component's only output the browser receives is the resulting HTML.

Q2

Why can't a Server Component use useState?

It never runs in a browser and is never hydrated into an interactive, re-renderable instance — it renders once, on the server, and produces static HTML. useState requires a live component instance that can re-render in response to changing state, which only exists for Client Components running in the browser.

Q3

What does the 'use client' directive at the top of a file actually do?

It marks that module (and everything it exports) as a Client Component, meaning its code is bundled and sent to the browser to be hydrated and run there — opting that specific piece of the tree into the interactive, hook-enabled behavior React has always had, rather than the server-only rendering that's now the default.

Q4

Why is marking an entire page 'use client' just because one button needs useState considered a mistake?

It forces the entire page's code to ship to and run in the browser, eliminating the server-rendering benefit for all the static content on that page too — effectively reverting the whole page to the pre-Next.js CSR model. Extracting just the interactive piece into its own small client component keeps the rest of the page server-rendered and JavaScript-free.