Post 02

Server vs Client Components, Kept Real

The Server/Client split confused almost everyone at some point — not because it is hard, but because people explain it like a compiler paper. Here is the plain-language version, plus the composition patterns that keep the boundary small.

Feb 2, 2025/9 min readNext.js
ShareY
Server vs Client Components, Kept Real

The Server Components model is genuinely a good idea, and the way it usually gets taught makes it sound far more complicated than it is. Here is the version I give people on their first day.

Backstage crew and performers

Think of your app as a theatre production.

  • Server Components are the backstage crew. They build the set, place the props, prepare the lighting — before the curtain goes up, and the audience never sees them. They run on the server, can read a database or a filesystem, and send finished HTML.
  • Client Components are the performers. They are on stage, reacting to the audience in real time. They run in the browser, use state and effects, and respond to clicks and typing.

The rule

Start every component on the server. Move the boundary to the client only at the point where you need interaction — state, effects, event handlers, browser APIs.

In the App Router, components are Server Components by default. You opt into the client with "use client" at the top of a file, and that marks that file and everything it imports as client code.

Where the boundary goes

The skill is putting "use client" as far down the tree as possible. A page that is mostly static with one interactive widget should be a Server Component that renders a small Client Component, not the reverse.

app/product/[id]/page.tsx — server by default
1export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {2  const { id } = await params;3  const product = await getProduct(id);4 5  return (6    <article>7      <ProductGallery images={product.images} />   {/* server */}8      <h1>{product.name}</h1>                        {/* server */}9      <RichText value={product.description} />       {/* server */}10      <AddToCartButton productId={product.id} />     {/* client — just this */}11    </article>12  );13}
Tip

Only AddToCartButton needs "use client". The gallery, the copy, the layout — all rendered on the server, none of it shipped as JavaScript. That is the whole payoff: less code in the browser.

Server Components render Client Components, not the reverse

A Server Component can import and render a Client Component directly. A Client Component cannot import a Server Component — but it can accept one as children or as a prop. That pattern lets you keep a server-rendered subtree inside a client-rendered shell.

components/collapsible.tsx — client shell, server content
1"use client";2import { useState } from "react";3 4export function Collapsible({ children }: { children: React.ReactNode }) {5  const [open, setOpen] = useState(false);6  return (7    <div>8      <button aria-expanded={open} onClick={() => setOpen(!open)}>9        {open ? "Hide" : "Show"} details10      </button>11      {open && children}   {/* this can be a Server Component */}12    </div>13  );14}
usage — the server component is passed in, not imported
1// app/page.tsx  (Server Component)2import { Collapsible } from "@/components/collapsible";3import { HeavyServerTable } from "@/components/heavy-server-table";4 5export default function Page() {6  return (7    <Collapsible>8      {/* rendered on the server, streamed as HTML into the client shell */}9      <HeavyServerTable />10    </Collapsible>11  );12}

The context provider pattern

Context needs a Client Component, but you want to wrap your whole server-rendered tree in it. The move: a thin client provider in layout.tsx, with {children} (server) passed through.

app/providers.tsx
1"use client";2import { ThemeProvider } from "next-themes";3import { QueryClientProvider, QueryClient } from "@tanstack/react-query";4import { useState } from "react";5 6export function Providers({ children }: { children: React.ReactNode }) {7  const [qc] = useState(() => new QueryClient());8  return (9    <QueryClientProvider client={qc}>10      <ThemeProvider attribute="class">{children}</ThemeProvider>11    </QueryClientProvider>12  );13}14// children stays a Server Component tree — only the providers are client

The cheat sheet

SituationServerClient
Fetching data for the pageYesNo
Reading secrets / env / the databaseYesNever
onClick, onChange, form interactivityNoYes
useState, useEffect, useRef, useContextNoYes
window, localStorage, browser APIsNoYes
Third-party components that use hooksNoYes

Do not overthink it. If it can run before the user shows up, it runs on the server. If it needs the user, it runs on the client. Everything else is keeping that boundary as small as you can.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.