Skip to main content
QuantLab Logo

MOFU Engineering Guide · 2026

Server Components vs Client Components, Explained

The single decision that shapes a Next.js codebase: which components render on the server and which run in the browser. Where the boundary goes, the mistakes that quietly bloat your bundle, and the performance math behind the rule.

By Bill Beltz, founder of QUANT LAB USA INC · Published June 3, 2026

Quick answer

Default to server components. Render everything on the server until a piece of UI genuinely needs state, an event handler, a ref, or a browser API — then mark only that leaf with 'use client'. Server components ship zero JavaScript and fetch data next to the database; client components own interactivity. Push the boundary as far down the tree as possible, pass data down as serializable props, and never let one stray 'use client' at the top pull your whole app into the browser bundle.

React Server Components are the most consequential change to React in a decade, and also the one that trips up the most experienced engineers — precisely because they have a decade of instinct that says "components run in the browser." In the Next.js App Router that instinct is wrong by default, and unlearning it is the whole job.

This post is the deep version of the boundary question we touch on in our Next.js 16 App Router guide. We draw this line on every web application we build, and getting it right is the difference between a 40 KB page and a 400 KB page. If you are new to the framework, start with what Next.js is.

Two component types, two runtimes

CapabilityServer componentClient component
Ships JS to the browserNoYes
Can be async / await dataYesNo (use effects or libraries)
useState / useEffectNoYes
onClick / onChange handlersNoYes
Read secrets / hit the databaseYesNo
Browser APIs (window, localStorage)NoYes (guarded)
Renders initial HTML on the serverYesYes, then hydrates

The last row is the one people miss. A client component is not a browser-only component — it renders on the server for the first paint and then hydrates in the browser. "Client" means "also runs in the browser," not "only runs in the browser."

Where the boundary actually goes

The boundary is the 'use client' directive. Everything in that file and everything it imports becomes part of the client bundle. The skill is placing it as deep in the tree as possible so the smallest amount of code crosses over.

Consider a product page with a static description and an interactive "add to cart" button. The wrong instinct is to make the whole page a client component. The right move is to keep the page on the server and isolate just the button:

// src/app/products/[id]/page.tsx  (server component)
import { getProduct } from "@/lib/products";
import { AddToCartButton } from "./AddToCartButton";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);   // server-side, no client JS

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* only this leaf becomes client JS */}
      <AddToCartButton productId={product.id} />
    </article>
  );
}
// src/app/products/[id]/AddToCartButton.tsx
'use client';

import { useState } from "react";

export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added" : "Add to cart"}
    </button>
  );
}

The product name, description, and markup ship as zero-JS HTML. Only the tiny button hydrates. On a page with dozens of products that is the difference between a snappy load and a janky one.

The children prop trick

The boundary is one-directional: a client component cannot import a server component. But you constantly need a client wrapper — a theme provider, an accordion, a tab panel — around server-rendered content. The answer is composition through children.

// Client wrapper that accepts server-rendered children
'use client';
import { useState } from "react";

export function Collapsible({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen((o) => !o)}>Toggle</button>
      {open && children}
    </div>
  );
}
// Server component fills the children slot — ServerHeavyPanel
// never enters the client bundle.
import { Collapsible } from "./Collapsible";
import { ServerHeavyPanel } from "./ServerHeavyPanel";

export default function Page() {
  return (
    <Collapsible>
      <ServerHeavyPanel />   {/* stays on the server */}
    </Collapsible>
  );
}

The client component controls the interactivity; the server component it wraps is rendered on the server and passed in as an already-rendered tree. This pattern is how you keep providers and interactive shells thin while the bulk of the page stays server-side.

Mid-post: get your boundary audited

Bundle feeling heavy? We audit App Router codebases for misplaced client boundaries and hydration cost. Free 30-minute review of your component tree.

The mistakes that bloat bundles

  1. 'use client' at the top of a layout or page. This is the cardinal sin. It marks the entire subtree as client, so every child — even static content — ships as JavaScript. Move the directive to the interactive leaf.
  2. Wrapping the app in a client provider directly. If your root layout renders a client <Providers> component that wraps children, that is fine — but only if Providers uses the children prop rather than importing the page tree. Keep providers as thin client shells.
  3. Pulling in a heavy library at the client boundary. A date picker, a charting library, or a markdown renderer imported into a client component drags its whole weight into the bundle. Render static output on the server where you can; lazy-load heavy client widgets with next/dynamic.
  4. Using useEffect to fetch first-paint data. If the data is needed to render, fetch it in a server component and pass it down. Client-side fetching adds a round-trip and a loading flash.
  5. Passing non-serializable props across the boundary. Functions, class instances, and Dates-as-methods do not serialize. The build will warn you; respect it and pass plain data plus a Server Action for behavior.
  6. Marking a whole UI-kit barrel file as client. If you re-export both server-safe and client-only components from one index.ts with 'use client' at the top, you force everything client. Split the barrel.

The performance math

The reason the default-server posture matters is hydration. Every client component that renders on first paint must be re-executed in the browser to attach event handlers — that is hydration, and it is CPU work that blocks interactivity, especially on mid-range phones.

  • Server component: ships HTML, ships zero JS for itself, costs nothing to hydrate.
  • Client component: ships HTML plus its JS plus its dependencies, and pays hydration CPU on load.
  • Net effect: moving a 60 KB interactive tree to a 6 KB leaf cuts both transfer size and main-thread hydration time by roughly 90% for that subtree.

For a content-heavy marketing page, going server-first routinely takes Time to Interactive from multiple seconds on a throttled mobile connection down to under a second. For a dashboard that is mostly interactive, the win is smaller because the interactivity is real — but even then, the static chrome, headers, and read-only panels belong on the server. The broader rendering background lives in our explainer on server-side rendering.

A decision checklist

When you are unsure which type a component should be, run it through these questions in order. The first "yes" in the client column decides it.

  1. Does it need useState, useReducer, or useEffect? If yes, client.
  2. Does it attach an event handler like onClick or onChange? If yes, client.
  3. Does it use a ref to a DOM node or a browser API? If yes, client.
  4. Does it depend on a third-party library that touches the DOM? If yes, client (and consider lazy-loading it).
  5. Does it only display data, read server resources, or compose other components? If yes, server.
  6. Default: server.

This is the same heuristic we apply when scoping SaaS platform builds and the internal tools covered in our platform engineering guide.

Frequently asked questions

What is the difference between a server component and a client component?

A server component runs only on the server, never ships its JavaScript to the browser, can be async, and can read secrets or hit the database directly. A client component runs in the browser, can use state, effects, refs, and event handlers, and gets hydrated after the HTML loads. In Next.js App Router every component is a server component by default; you opt into client behavior with the 'use client' directive at the top of the file.

When should I use a client component?

Use a client component only when you need something the browser provides: React state or effects, event handlers like onClick or onChange, refs, browser APIs such as localStorage or the geolocation API, or third-party libraries that touch the DOM. Everything else — data fetching, layout, static content, and anything that reads server-only resources — should stay a server component. The rule is to reach for the client only when interactivity forces you to.

Can a server component import a client component?

Yes, and that is the normal direction. A server component can render a client component freely. What you cannot do is the reverse: a client component cannot import a server component directly, because by the time the client module runs, the server has finished. The workaround is composition — pass the server-rendered content into the client component as a children prop, which the server fills in before hydration.

Does 'use client' mean the component only runs in the browser?

No — that is the single most common misunderstanding. A client component still renders on the server for the initial HTML (server-side rendering), then hydrates and re-runs in the browser. The 'use client' directive does not turn off server rendering; it marks the start of the client bundle. The component runs in both places, which is why you must guard browser-only code like window or localStorage behind an effect or a typeof check.

Are server components always faster than client components?

Not categorically, but they win on the metrics that usually matter. Server components ship zero JavaScript for themselves, which shrinks the bundle and cuts hydration cost — the biggest lever on Time to Interactive for content-heavy pages. They also move data fetching next to the database, eliminating a client round-trip. Where client components win is interactivity: anything that must respond instantly to user input without a server round-trip belongs on the client.

How do I share data between server and client components?

Fetch the data in a server component and pass it down as props to the client component. Props crossing the boundary must be serializable — plain objects, arrays, strings, numbers, dates — not functions or class instances. For app-wide client state, put the provider in a client component near the root of a layout and wrap server-rendered children with it via the children prop so you do not pull the whole tree into the client bundle.

Can QUANT LAB USA review my component architecture?

Yes. We audit Next.js App Router codebases for client-boundary placement, bundle bloat, and hydration cost as part of our web application work, and we build new applications with the boundary drawn correctly from day one. A typical architecture review is a fixed-scope engagement; a full build runs 8 to 24 weeks. Book a call below and we will look at your tree.

Drawing the boundary right.

Free 30-minute review. We will look at your component tree, find the misplaced client boundaries, and map the path to a leaner bundle.

Or call Bill directly at (770) 652-1282
All blog postsUpdated June 3, 2026