Lesson 01 of 04

Where your code actually runs

Intermediate9 min

Server and client components are not a performance setting. They are a boundary in your program, and the boundary has rules.

OutcomesAfter this you will be able to

  • Explain what `"use client"` does to the module graph
  • Predict which components ship JavaScript to the browser
  • Pass a server component through a client component without breaking the boundary

01Everything is a server component until you say otherwise

In the App Router every component is a server component by default. It runs on the server, during the request, and its code is never sent to the browser. It can read the filesystem, query the database, and use secrets — because none of it leaves the server.

What it cannot do is anything stateful or interactive. No `useState`, no `useEffect`, no event handlers, no browser APIs. Those need `"use client"`.

app/orders/page.tsx
// no directive = server component.
// this SQL never reaches the browser, and neither does the client.
import { db } from '@/lib/db';

export default async function OrdersPage() {
  const orders = await db.query.orders.findMany({ limit: 50 });
  return <OrderTable orders={orders} />;
}

02"use client" marks an entry point, not a single file

This is the detail that trips everyone up. `"use client"` does not mean "this component is a client component". It means "from here down, everything imported is part of the client bundle."

If a client component imports a utility, that utility ships to the browser. If that utility imports your database module, you get a build error — or worse, you leak something. The directive marks the boundary of the client half of your program.

03Passing server components through client components

A client component cannot import a server component. It can render one passed to it as a prop. This is the escape hatch that keeps interactive shells — accordions, tabs, modals — from dragging their whole contents into the browser bundle.

tsx
// ✗ this pulls HeavyServerThing into the client bundle
'use client';
import { HeavyServerThing } from './heavy';
export function Panel() {
  const [open, setOpen] = useState(false);
  return open && <HeavyServerThing />;
}

// ✓ the shell is interactive, the contents stay on the server
'use client';
export function Panel({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children}
    </>
  );
}

// app/page.tsx — server component composes the two
<Panel>
  <HeavyServerThing />
</Panel>

The rendered output of the server component is serialised and handed to the client component as an already-rendered tree. The client component never sees its source, so its dependencies never ship.

04A rule that holds up

  • Start every component on the server.
  • Push `"use client"` as far down the tree as it will go — to the button, not the page.
  • If a client component needs server data, pass it as a prop rather than fetching it in an effect.
  • When you need both, make the client component a shell and pass server-rendered children into it.

Exercise

Find your boundary

Run a bundle analyser on an existing Next.js app and find the largest client chunk. Trace it back to the `"use client"` file that pulled it in. Nine times out of ten the directive is one or two levels higher in the tree than it needs to be — move it down and measure again.