Skip to content
← All insights
Frontend

The mental model that makes Server Components click

5 min read

Server Components stop being confusing once you stop thinking about where code runs and start thinking about when it runs.

Most of the confusion around React Server Components comes from framing them as 'components that run on the server'. That's true and it isn't the useful part. The useful frame is time, not place.

Two moments, not two machines

A Server Component runs once, at render time, and produces a description of UI. A Client Component runs at render time and then again on every interaction, forever. The boundary between them isn't a network boundary — it's the line between what happens once and what happens repeatedly.

Once you hold that, the rules stop needing memorisation. You can't pass a function across the boundary because a function is a thing that happens later, and the description sent to the browser is already finished. You can pass data, because data is a result.

tsx
// Server: runs once. Reads data directly, ships no JS.
export default async function ProjectPage({ params }: PageProps<"/p/[id]">) {
  const { id } = await params;
  const project = await db.project.find(id);

  return (
    <article>
      <h1>{project.name}</h1>
      {/* Only this subtree becomes interactive JavaScript. */}
      <StatusToggle projectId={project.id} initial={project.status} />
    </article>
  );
}

Push the boundary down, not up

The instinct when something needs interactivity is to mark the page as a Client Component. It works, and it hands the entire subtree to the browser as JavaScript — including the parts that never change.

The better move is almost always to push the boundary as far down the tree as it will go. Make the toggle a Client Component, not the page that contains it. A page of static content with three interactive controls should ship three interactive controls' worth of JavaScript.

Rule of thumb: a `use client` directive near the top of your tree is a bug report about component structure, not a decision.

Children are the escape hatch

The rule people trip over: a Client Component can't import a Server Component. But it can render one passed as `children`. That single fact resolves most of the awkward cases — an interactive shell wrapping server-rendered content is not just allowed, it's the intended pattern.

tsx
// Server Component composes both. The accordion is interactive;
// its contents were rendered on the server and ship no JS.
<Accordion>
  <ExpensiveServerRenderedReport id={id} />
</Accordion>

What actually changes in practice

  • Data fetching moves next to the thing that needs it, and the loading-state waterfall you used to hand-manage becomes a Suspense boundary.
  • The state management layer shrinks. A lot of client state was only ever a cache of server data.
  • Bundle size becomes a structural property of your component tree rather than something you audit after the fact.

None of that requires new APIs to memorise. It requires asking one question of every component: does this need to happen again after the page loads? If not, it belongs on the server.

Tell me what you're building.

A 30-minute call. You'll leave with a straight read on scope, sequencing and what it takes to get there — whether or not we work together.

Typically replies within one business day