CodeOath
← All posts
Next.js31 min total · 12 parts

Next.js Fundamentals: The App Router, Server Components, and the Caching Layer Nobody Warns You About

Part 4 of 12 · ~4 min

Server Components vs Client Components

This is the single biggest mental shift coming from plain React, and getting it wrong is the most common way people write worse, slower Next.js apps than the framework was ever forcing them to.

By default, every component in the app/ directory is a Server Component. It renders entirely on the server, produces HTML (and a compact serialized description of the component tree), and — this is the part that surprises people — ships zero JavaScript for that component to the browser. Not "less JavaScript." None. If a Server Component imports a 200KB date-formatting library, that library's code never reaches the client at all; it ran once, on the server, and only its output made it into the response.

// app/products/[id]/page.tsx — a Server Component, no directive needed
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`);
  return res.json();
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </article>
  );
}

Notice this component is declared async and awaits fetch directly in its body — something a plain React component can never do (React components have to render synchronously; there's no such thing as an async function component in the browser). Server Components can do this because they run once, server-side, before any HTML is sent — there's no render loop waiting on them in the browser.

A Client Component is any component in a file that starts with the "use client" directive:

"use client";

import { useState } from "react";

export default function LikeButton() {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked((v) => !v)}>{liked ? "Liked" : "Like"}</button>;
}

This is the part that trips people up: "use client" doesn't mean "this file runs in the browser instead of the server." It means "this file — and everything it imports that doesn't itself have its own "use client" — becomes part of the client bundle, gets rendered on the server for the initial HTML and then hydrated in the browser, and is allowed to use browser-only APIs and interactive hooks (useState, useEffect, event handlers)." It marks a boundary in the component tree, not a property of one isolated file. Everything below that boundary is part of the client-rendered subtree by default, unless a component you import there is itself a Server Component being passed in through children (a real, useful escape hatch, but one that only works through composition — a Client Component cannot directly import and render a Server Component as a descendant).

The mistake of sprinkling "use client" everywhere

The instinct, especially early on, is: something isn't working, add "use client", it works, move on. Do that at the top of your layout or a high-level page and you've just opted a huge swath of your tree — everything nested beneath it — out of the server-only, zero-JS default, even for components underneath that never actually needed interactivity.

// Overly broad — this drags the ENTIRE page's component tree into the client bundle
"use client";

export default function ProductPage({ product }: { product: Product }) {
  return (
    <div>
      <ProductHeader product={product} />   {/* now client-rendered too, whether it needs to be or not */}
      <ProductDescription product={product} />  {/* same */}
      <AddToCartButton productId={product.id} /> {/* the only piece that actually needs interactivity */}
    </div>
  );
}

The fix is pushing "use client" down to the smallest actually-interactive leaf, and letting everything else stay a Server Component:

// app/products/[id]/page.tsx — stays a Server Component
export default function ProductPage({ product }: { product: Product }) {
  return (
    <div>
      <ProductHeader product={product} />
      <ProductDescription product={product} />
      <AddToCartButton productId={product.id} /> {/* only this one is a Client Component */}
    </div>
  );
}
// app/products/[id]/AddToCartButton.tsx
"use client";

import { useState } from "react";

export default function AddToCartButton({ productId }: { productId: string }) {
  const [pending, setPending] = useState(false);
  return (
    <button disabled={pending} onClick={() => setPending(true)}>
      {pending ? "Adding…" : "Add to cart"}
    </button>
  );
}

A useful rule of thumb: reach for "use client" only when a component genuinely needs one of three things — interactivity via hooks like useState/useReducer, a browser-only API (window, localStorage, IntersectionObserver), or a lifecycle effect via useEffect. Everything else — fetching and rendering data, static markup, formatting — is happy staying server-only, and every component you leave server-only is JavaScript your users never have to download, parse, or execute.