Skip to main content
ASoc
Comparison

Server Components vs Client Components: Where the Boundary Actually Goes

24 of this site's 91 components are client components, and not one is an atom. The cost is never the component — it is the module graph it drags across the boundary.

The ASoc Team14 min read

A Server Component is the default and costs the browser nothing but markup. A Client Component ships its code, its imports and its imports' imports. So the question is never "is this interactive?" — it is "what does this file drag across the boundary with it?" Answer that and the split decides itself.

This storefront has 91 components across four layers. Exactly 24 of them carry "use client" — 19 molecules and 5 organisms, and not one atom or template. That distribution was not planned. It is what fell out of applying one rule for two years, and the three times we got it wrong are more instructive than the times we got it right. It is also the number that decides how this model compares to a fine-grained-reactivity framework, which is the argument in SolidJS vs. Next.js.

The rule that is usually stated, and why it is not enough

Every guide lands on the same heuristic: use a Server Component unless you need state, effects, event handlers or browser APIs. That is correct and it is not sufficient, because it tells you which component must be a Client Component and says nothing about how much that costs.

The cost is not the component. It is the module graph rooted at it. Once a file is marked "use client", everything it imports at module scope is client code too — transitively, including things you did not think of as UI.

Three real examples from this repo, in increasing order of how much they hurt.

Mistake 1: an SDK imported at module scope in a shared component

The site-wide header shows account state when you are signed in. Reasonable requirement, and it makes Header a Client Component — no argument there.

What was wrong was the import:

"use client";
import { createBrowserClient } from "@supabase/ssr";

At module scope. Header renders on every page, so @supabase/ssr plus auth-js~68 KiB gzipped, 255 KiB parsed — sat in the initial bundle of the home page, the blog, the docs and the pricing page. Four pages with no account UI at all, each parsing an auth SDK before they could settle.

The component only ever touched the client inside an effect. So the fix was to stop importing it and start loading it:

// src/lib/supabase/lazyClient.ts
export function hasAuthCookie(): boolean {
  return document.cookie.includes("-auth-token=");
}

export async function loadSupabaseClient() {
  const { createBrowserClient } = await import("@supabase/ssr");
  return createBrowserClient(url, anonKey);
}

Two things happen here. The dynamic import() moves the SDK out of the initial chunk, and the cookie probe skips it entirely for anyone without a session. @supabase/ssr sets httpOnly: false by design — createBrowserClient reads those cookies from document.cookie itself — so the probe sees exactly what the client would see. For an anonymous visitor, and for every crawler, the auth stack is now never fetched.

Say the general form out loud, because it generalises past auth: "use client" marks a boundary; a module-scope import carries things across it. A dynamic import inside a handler or an effect does not.

To be explicit about the security half, since this is a loading trick applied to an auth SDK: it is a rendering shortcut only. Every real gate stays server-side — entitlement checks, row-level security, the download authorisation. A tampered cookie makes the browser download an SDK it did not need. It does not grant anything.

Mistake 2: a data module behind a client feature

The saved-templates wishlist is localStorage-backed, so it is client code by definition. The obvious implementation stores slugs, and the header's side-sheet resolves each slug to a name and thumbnail for display.

That resolution needs the catalog. src/data/catalog.ts is 7,986 lines. Importing it into a component the header renders on every page puts the entire product database — every description, every changelog entry, every screenshot path for 111 products — into the global client bundle, to paint a name and a 60px image.

The comment on the fix is the whole argument:

/**
 * WHY DENORMALIZED ENTRIES (slug + name + image) rather than bare slugs: the
 * header's saved side-sheet renders on EVERY page. Resolving a slug to a name
 * and thumbnail would mean importing `src/data/catalog.ts` — a ~7k-line data
 * module — into the global client bundle. Storing the three fields the UI
 * actually paints keeps the wishlist's client cost near zero.
 */

The trade is stated in the same comment: the catalog remains the source of truth for everything that matters — price, editions, availability — and a renamed product shows a stale label in the saved list until it is re-saved. That is the correct thing to give up. Normalisation is a server-side virtue; across the client boundary, a denormalised copy of the three fields you render is usually cheaper than the lookup table.

Mistake 3: reaching for the interactive component out of habit

Every product page closes with a rail of six sibling templates. The natural move is to reuse TemplateCard, the card the /templates grid already uses.

We wrote a second card instead, and the file says why:

/**
 * Deliberately not `TemplateCard`: that one is a client component carrying a
 * preview modal, a wishlist button, an owner download menu and an ownership
 * lookup. Six of them under every product page would ship that interactivity
 * for a navigation rail that only needs to be a link. This stays a Server
 * Component, so the whole rail costs nothing but markup — which is also what
 * makes it crawlable without JavaScript.
 */

This is the case the standard heuristic misses completely. TemplateCard does need to be a Client Component — in the grid. In the rail it needs to be a link. Same data, same visual, different job, and the second job has no interactivity in it at all. Duplicating ~40 lines of markup was cheaper than shipping four features six times per page.

The general question to ask at a reuse site is not "does this component do what I need?" but "does this component do more than I need, and does the extra travel to the browser?"

Where the boundary actually landed

LayerFilesClient ComponentsWhat that means
Atoms60Primitives — a container, a button, a heading. Pure markup.
Molecules4119Where interaction lives: accordions, carousels, forms, pickers.
Organisms335Sections. Mostly map over data and render molecules.
Templates110Page layout. Order organisms, hold nothing.

Two things stand out. The boundary is almost entirely at the molecule layer — the smallest unit that owns a piece of state. And the five client organisms are exactly the ones you would expect from their names: the header, the templates explorer (filter state), and three dashboard grids behind auth.

Nobody designed that. It happened because interactivity has a natural size, and it is smaller than a page section. When a Client Component keeps growing, it is usually because a server-shaped concern got dragged in with it. The resulting ratio — 68 of 92 components never reaching the browser — is also the clearest answer to whether this framework is a frontend one.

The five things that force a Client Component

Worth being precise, because the list is short and everything else is a Server Component by default:

  1. State or effectsuseState, useReducer, useEffect, useSyncExternalStore.
  2. Event handlersonClick, onChange. A handler cannot be serialised into HTML.
  3. Browser APIswindow, document, localStorage, IntersectionObserver.
  4. Class components and most third-party UI libraries — anything that uses the above internally.
  5. Context providers and consumers — React Context does not cross the boundary.

Not on the list: async/await, database access, secrets, filesystem reads. Those are Server Component advantages, and they are the reason a page's data fetching should sit above the boundary rather than below it.

Passing data down: what actually crosses

A Server Component can render a Client Component and pass it props. Those props are serialised, which is the constraint people hit first:

// Organism (Server Component) — reads the data file, computes the label
const frameworks = product.editions
  .filter((e) => e.status === "ready")
  .map((e) => FRAMEWORK_LABELS[e.framework])
  .join(" · ");

return <TemplateCard product={product} frameworksLabel={frameworks} />;

Functions, class instances, Date methods and Symbols do not survive. Plain objects, arrays, strings, numbers and JSX do.

The architectural rule we ended up with is stricter than serialisation requires: a molecule never imports a runtime value from a data file — type-only imports are fine, and the organism computes and passes what is needed. That is exactly what frameworksLabel is above. It reads like a purity rule about layering. It is really a bundle rule: it means no leaf component can quietly reach for a 7,986-line module.

The other direction is worth knowing because it is the escape hatch people miss: a Client Component can render a Server Component passed to it as children. The parent is client, the child is server, and the child's dependencies never cross:

<ClientAccordion>
  <ServerRenderedArticle />   {/* stays on the server */}
</ClientAccordion>

If a client wrapper is dragging a large subtree with it, this is almost always the fix.

The other half: one lookup, not a dozen

The cost of a Client Component is not only bytes. Twelve cards in a grid, each wanting to know whether the viewer owns that product, is twelve getUser() round trips and twelve server actions all returning the same answer.

The hook memoises at module scope:

let lookupPromise: Promise<Owned> | null = null;

function loadOwnership(): Promise<Owned> {
  lookupPromise ??= (async () => {
    if (!hasAuthCookie()) return NOBODY;
    const supabase = await loadSupabaseClient();
    const { data: { user } } = await supabase.auth.getUser();
    if (!user) return NOBODY;
    return { signedIn: true, slugs: await getOwnedProductSlugs() };
  })();
  return lookupPromise;
}

One in-flight lookup per page load, shared by every card. The cache lives as long as the JS module does, which is the right lifetime: the two things that change ownership — completing a checkout, signing in or out — both end in a full document load, which resets it.

Server Components get this deduplication for free from React's request-scoped cache. On the client you have to build it, and a grid is exactly the shape that makes you notice.

What none of this buys you

Be honest about the ceiling. This site scores 100 on desktop Lighthouse performance across eight page types; mobile lands at 89–99. Every fix above is done, and mobile is still not 100.

So we tested the obvious hypothesis directly: with every image and every RSC prefetch blocked, the home page's LCP moved from 3786 ms to 3740 ms. Forty-six milliseconds. The remaining cost is React hydration, and the largest single chunk is react-dom itself at 221 KiB raw / 70 KiB gzipped.

That is the floor. Moving work to Server Components removes your JavaScript; it does not remove React's. If your target is a perfect score on a mid-range phone, the remaining lever is shipping less interactivity — a product decision, not an architecture one. We deliberately did not take it: the preview modal, the wishlist and the owner download menu on every card are the storefront.

When a Client Component is the right answer

The framing above is one-sided on purpose, so here is the other side. Reach for the client boundary without hesitation when:

  • The interaction must survive without a round trip. A carousel that fetched a slide per click would be worse in every way than one that ships its slides and translates a track.
  • The state is genuinely browser-local. A wishlist for logged-out visitors has no server-side identity to attach to. Making it an account feature would defeat the feature.
  • The alternative is making a page dynamic. Resolving account state client-side is what keeps every marketing page a prerendered file — a cookies() call in the root layout would make the entire application dynamic instead.

That third one is the trade most worth understanding: a small client bundle on a static page usually beats no client bundle on a per-request page.

Mistakes and how they show up

MistakeSymptomFix
SDK imported at module scope in a shared client componentVendor chunk on pages with no such featureDynamic import() inside the effect, behind a cheap probe
Large data module imported by a client leafGlobal bundle grows for a label and a thumbnailDenormalise the fields you paint; pass them as props
Reusing an interactive card in a static railModal, wishlist and auth shipped N times per pageA second, server-rendered component for the read-only job
"use client" on a page sectionWhole subtree becomes client codePush it down to the smallest unit that owns state
Per-item auth or ownership lookups in a gridN identical round trips per page loadMemoise one promise at module scope
Passing a function or class instance as a propSerialisation error at the boundaryPass data; keep behaviour on one side
Client wrapper around server-heavy contentContent's dependencies cross the boundaryPass the server subtree as children
Assuming Server Components fix mobile performanceScore barely movesMeasure — the floor is react-dom, not your code

Frequently asked questions

Does "use client" mean the component is not server-rendered? No, and this is the most common misreading. Client Components are still prerendered to HTML on the server; the directive marks where hydration begins and which code the browser must download. The name describes the boundary, not the rendering location.

Should a shared UI library be marked "use client" at the top level? Only the components that need it. A blanket directive on an index file marks everything it re-exports, which is how a design system ends up entirely on the client. Mark leaves, not barrels.

How do I find what is actually crossing the boundary? Run a bundle analyser and look at the initial chunk for a page that should have no interactivity — our home page and docs page are how we found the 68 KiB. If a vendor package appears on a page with no feature that uses it, follow its importers up to the nearest "use client".

Do React (Vite) template editions have any of this? No — there is no server boundary in a client-rendered Vite app, so every component is a Client Component and the entire decision disappears. That is a real simplification, and it is also why the bundle discipline above matters more there, not less: there is no layer to move work to. Which starting point suits which project is its own question.

Is there a lint rule for any of this? Partially. The framework errors on serialisation violations at build time, but nothing warns you that a Client Component imported an 8,000-line module or that a rail shipped a modal six times. Those are review questions today. A CI check on initial-chunk size per route is the version that scales.

Templates that keep the boundary where it belongs

The hardest version of this is a marketing site whose design wants live-looking product surfaces — a control-room dashboard, a capability explorer, a pricing toggle — on a page that has every reason to be a static file. The templates below take that shape: a prerendered server-rendered shell with the interactive parts isolated to the smallest components that need them.

Keep reading

Comparison8 min read

React vs. Gatsby: The Real Question Is the GraphQL Layer

Gatsby adds a GraphQL data layer on top of React. This catalog imports typed data directly instead, with zero GraphQL and zero content plugins.

Read more
Comparison8 min read

React vs. Remix: A Mismatched Pairing, and the Real Question

Remix runs on React — they aren't competitors. The real comparison is Next.js vs. React Router v7, and it comes down to which way the static default points.

Read more