Server and client components

Know which side a component runs on, what crosses between them, and how to keep the boundary small.

06of 16
≈ 14Min read
7Parts

Server by default

Every component in the App Router is a server component until something says otherwise. That is the opposite of what React has meant for ten years, and it is the single idea this lesson is about.

Server componentClient component
RunsOn the server, at request or build timeOn the server once (HTML), then in the browser
Ships JavaScriptNoneYes — it is in the bundle
May be asyncYesNo
Database, secrets, fsYesNever
useState, useEffectNoYes
onClick, onChangeNoYes
window, localStorageNoYes

The boundary

  • The directive marks an entry point, not one component: every module imported from that file is client code too.
  • One "use client" near a leaf costs a few kilobytes. One at the root of the tree ships the whole application.
  • A client component cannot import a server component — but it can receive one as children.
"use client" at the top of the file
"use client";

import { useState } from "react";

export function Quantity({ initial }) {
  const [n, setN] = useState(initial);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Passing a server component through

children is created where it is written — on the server — and handed to the client component as an already-rendered tree. The interactivity is in the wrapper; the data and the markup stay on the server.

the pattern that keeps the boundary small
// app/page.tsx — a server component
export default async function Page() {
  const doors = await db.doors.all();      // server only

  return (
    <Accordion>                            {/* client: it has the open/closed state */}
      <DoorList doors={doors} />           {/* still a SERVER component */}
    </Accordion>
  );
}

What can cross

props are serialised, so they must be serialisable
<Client
  name="Painted"          // yes: strings, numbers, booleans, null, undefined
  door={{ id: 1 }}        // yes: plain objects and arrays, any depth
  when={new Date()}       // yes: Date, Map, Set, BigInt, promises
  onSave={saveDoor}       // only if saveDoor is a "use server" action
  format={(d) => d}       // NO: an ordinary function
  row={new DoorRow()}     // NO: a class instance — the methods do not survive
/>
An ORM row is usually a class instance

Prisma and friends often return objects that look plain and are not. Map them to the fields you need before passing them down — which you should be doing anyway, so a password hash does not travel to the browser.

Keep the client part at the leaves

The question to ask about any component is: does it need state, an effect, an event handler or a browser API? If not, it has no reason to be in the bundle at all.

the same page, two bundles
// bad: the whole page is client code because one button needs state
"use client";
export default function Page() { … <button onClick={…}> … }

// good: the page stays on the server, one small component is client
export default async function Page() {
  const doors = await getDoors();
  return (<><DoorTable doors={doors} /><AddButton /></>);   // AddButton: "use client"
}

Secrets, and the direction of trust

Server components make it easy to query the database directly from a component. That is genuinely convenient, and it means an accidental "use client" at the top of that file is a build error rather than a leak — which is the safety the boundary buys.

a server component can read them; a client one cannot
// server component — fine
const key = process.env.STRIPE_SECRET_KEY;

// client component — undefined, unless the name starts with NEXT_PUBLIC_,
// and anything NEXT_PUBLIC_ is visible to every visitor. It is not a secret.

Hydration, and the errors it produces

the server HTML and the first client render must agree
// "Text content did not match" — three classic causes
<p>{new Date().toLocaleString()}</p>       // different clock
<p>{Math.random()}</p>                      // different value
<p>{localStorage.getItem("theme")}</p>      // not available on the server

// render it after mount instead
const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
return <p>{ready ? new Date().toLocaleString() : null}</p>;

Practice