Web Design and Engineering

Server Components vs Client Components: the decision tree we follow

The five-step decision tree we apply on every Next.js 16 project to decide when a component stays server-side and when it needs "use client".

April 20, 20267 min read
Server Components vs Client Components: the decision tree we follow

By the end of this guide you will have a clear, repeatable rule for deciding when to reach for a Server Component and when to mark a file with "use client". It is the same decision tree we apply on every Next.js 16 project we ship, and it removes most of the "where does this logic belong?" debates in pull request review.

The question is practical, not theological. Next.js 16 and React 19 make Server Components the default, so every component inside app/ starts on the server unless you opt out. The mistake most teams make is the inverse: they mark the whole page "use client" as soon as one child needs state, then ship a JavaScript bundle that rebuilds the entire tree in the browser for no reason.

Before you start

  • A Next.js 16 project using the App Router (app/ directory).
  • React 19 (installed by default in Next.js 16).
  • Familiarity with the two directives: "use client" and "use server". This guide focuses on the first.

The five-step decision tree

Apply the steps in order. Stop at the first step whose answer is yes.

Step 1. Does the component need state, events, effects, or browser APIs?

This is the only question the official React documentation treats as a hard rule. Client Components are required when you use useState, useEffect, onClick-style event handlers, localStorage, window, navigator, or any third-party hook that wraps one of those. If the answer is yes, put "use client" at the top of the file, above the imports, and move on (React docs on the directive).

If the answer is no, default to a Server Component. No directive needed. Server Components are the default in Next.js 16 (Next.js server and client components).

Step 2. Can you push the "use client" boundary further down the tree?

This is the step teams skip most often. When a file is marked "use client", every file it imports becomes part of the client bundle. That includes any server-only logic you dragged along by accident. The fix is to isolate the interactive piece into its own file and keep the parent server-side.

Concrete example: a product detail page with a "Buy" button. The page fetches the product from the database (server work), renders most of the layout (static), and needs one interactive piece for the cart. The button is the Client Component. The page, the image, the description, and the spec table stay on the server. This keeps the JavaScript payload to the button and its dependencies only.

Step 3. Does interactivity need data from a Server Component?

You cannot import a Server Component into a Client Component. The React runtime is one-way: server renders first, client hydrates second. Importing across the boundary would force the client to reach back to the server, which is precisely what React Server Components exist to avoid.

The supported pattern is composition through children or other React-node props. A parent Server Component imports both the Client Component and the Server Component, then passes the Server Component as a child. The Client Component receives it as opaque JSX and decides where to place it (Next.js composition patterns). We use this shape for modals, drawers, and tabs that need to host server-rendered content.

Step 4. Is the component used only inside a Client subtree?

If the answer is yes, the component is effectively a Client Component already. A single "use client" directive at the boundary flips the whole subtree. Adding the directive to every child is redundant and noisy in code review. Put it only at the entry point where a Server Component imports into the client world.

Step 5. Measure before you migrate.

The bundle-size savings from moving work to the server vary by orders of magnitude. Frigade documented a 62% bundle reduction after an RSC migration because a large markdown renderer moved to the server (Frigade engineering, 2024). Other apps see single-digit percent wins. The difference is whether the libraries you host client-side can actually run on the server. Syntax highlighters, markdown parsers, date formatters, and i18n helpers are usually portable. Rich text editors, chart libraries, and map libraries usually are not.

Before you promise a team a performance gain, run @next/bundle-analyzer or inspect next build output, flag the top five client imports, and check whether any of them can move server-side. If none can, the decision tree stops at its current shape and the payoff is small.

Verifying it works

  • Network tab. Server Components emit HTML with no matching JavaScript chunk for their content. Open DevTools, hard-refresh, and confirm the initial HTML contains your text. If the text only appears after JavaScript loads, the component is on the client.
  • Bundle analyzer. Run ANALYZE=true next build with @next/bundle-analyzer configured. Client Component leaves appear as named chunks. If you see a chunk for something that should be static, the "use client" boundary sits higher than it should.
  • View source. Search the returned HTML for your static text. Present in the HTML: server-rendered. Only inside a <script> tag: client-rendered.

Common failures and fixes

Error: "Functions cannot be passed directly to Client Components." You are passing a function prop from a Server Component to a Client Component. Inline the logic on the client, or use a Server Action ("use server" directive on the function) so the runtime marshals it across the boundary.

Hydration mismatch on a supposedly static component. A third-party library you import uses React hooks internally, which tainted the file as client-executed. Move the import behind a Client Component wrapper, or replace the library.

Whole page marked "use client". The telltale sign is a single large JavaScript chunk for the route. Split the page: keep page.tsx server, move the interactive block into a sibling file, import the sibling from the server page.

Context providers at the root. Theme providers, query clients, and auth providers are Client Components that wrap the whole app. That is fine. They do not promote their children to the client unless the children themselves are client. Next.js still emits server HTML for Server-Component children wrapped inside a client provider.

Data fetching in a Client Component with useEffect. This is a Pages Router pattern. In App Router, fetch in the Server Component, pass the data down as a prop, and let the Client Component render it. When the data is slow, wrap the fetching component in <Suspense> and stream it (Next.js streaming guide).

Going further

Two surfaces naturally extend this tree. For data mutations, reach for Server Actions ("use server" directive on an async function). For progressive rendering of slow server data without blocking the page, combine Suspense with loading.tsx.

Josh W. Comeau's mental model of Server Components is the best external primer we have read (Making Sense of React Server Components). His framing of Server Components as a build-time output rather than a runtime paradigm helps teams coming from the SSR-plus-hydration world of Next.js 13 and earlier. If you are still operating inside that model, the first payoff is conceptual, not performance.

The rule we return to on every project: the default is server. The exception is documented, local, and as small as it can be. That rule has stopped more bundle regressions than any lint we have tried.

Sources

Photo by Kelly Sikkema on Unsplash

Studio

Start a project.

One partner for companies, public sector, startups and SaaS. Faster delivery, modern tech, lower costs. One team, one invoice.