DField SolutionsMérnöki stúdió · Budapest
Loading · Töltődik
Skip to content
Back to blog
·11 min read
React··11 min read

Server vs. Client Components in 2026 · the rule we apply

Two years into the RSC era, the decision tree got simpler. Here is the one we apply, with the wrong answers we already paid for.

Last verified
Dezso Mezo
Founder, DField Solutions
ShareXLinkedIn#
Server vs. Client Components in 2026 · the rule we apply

Two years into shipping React Server Components in production, the gut feel finally stabilised. Most teams we work with still treat the choice as religious · 'we are an RSC shop' or 'just put `'use client'` on top, it works'. Both lose money. The actual rule is shorter than either camp wants to admit.

We default every component to a Server Component, and we only promote it to a Client Component when one of four triggers fires. That is the whole rule. The rest of this post is the four triggers, the cost we pay for getting it wrong, and the cases where the right answer is 'neither, refactor the boundary'.

The default is server

If a component renders data, lays out content, composes other components, or calls a database, it is a Server Component. No state, no `useEffect`, no event handlers · just `async` functions and JSX. We do not put `'use client'` on a card, a list, a layout, or a section header by reflex anymore. That habit was leftover from the Pages Router years and it ships JS for nothing.

The four triggers to promote to client

  1. It needs `useState`, `useReducer`, `useContext` or any other hook that lives across renders. A controlled input. A modal that opens. A tab that switches.
  2. It calls a browser API: `window`, `document`, `navigator`, `IntersectionObserver`, `requestAnimationFrame`, `localStorage`, geolocation, web workers.
  3. It uses a third-party library that imports from a browser-only entry · most maps, most rich-text editors, most charts.
  4. It needs an interaction handler that the server cannot pre-render · `onClick`, `onChange`, drag-and-drop, focus traps.

Anything else is a server component. If you are unsure between '4' and 'this is just a link', the answer is server · a link is `<a href>`, not an `onClick` handler.

The cost we pay when we get it wrong

A component that should have been server, marked client: extra JS to download, parse, hydrate. Every prop crossing the boundary becomes serialisation cost. Suddenly your nice composable tree carries a JSON blob that flows down with the HTML. Two extra `'use client'` files in a layout is how a marketing page loses 40KB of bundle for no gain.

A component that should have been client, kept on the server: it does not interact, the user clicks and nothing happens, or the dev wraps the parent in `'use client'` to fix it. That last move is the most expensive · now the entire subtree, including its server-only children, has to be reauthored.

The boundary is the unit of design

The thing nobody tells you on day one: the boundary between server and client is a real architectural artefact. You design it, you name it, you keep it small. We name our client islands after their job · `<TabsClient>`, `<EditorClient>`, `<MapClient>`. The server wrapper next to them stays composable and data-fetching. Anything else is mush.

// app/dashboard/page.tsx · server
import { TabsClient } from "./tabs-client";
import { fetchSummary, fetchActivity } from "@/lib/data";

export default async function Page() {
  const [summary, activity] = await Promise.all([
    fetchSummary(),
    fetchActivity(),
  ]);

  return (
    <section>
      <h1>Dashboard</h1>
      <TabsClient
        // server-rendered children pre-fill the tabs
        summary={<SummaryView data={summary} />}
        activity={<ActivityList items={activity} />}
      />
    </section>
  );
}
// app/dashboard/tabs-client.tsx
'use client';
import { useState, type ReactNode } from "react";

export function TabsClient({ summary, activity }: { summary: ReactNode; activity: ReactNode }) {
  const [tab, setTab] = useState<"summary" | "activity">("summary");
  return (
    <>
      <nav role="tablist">
        <button onClick={() => setTab("summary")}>Summary</button>
        <button onClick={() => setTab("activity")}>Activity</button>
      </nav>
      {tab === "summary" ? summary : activity}
    </>
  );
}

Notice what is in the client file: state, two buttons, a switch. That is it. The rendered children of each tab are server components passed in as props. We do not move data fetching across the boundary, we move slots.

Three patterns we use weekly

Pattern A · Server data, client interaction

Fetch on the server, then hand the result to a small client island. Best for filters, tabs, drawers, accordions. Keep the interactive surface tiny.

Pattern B · Server actions for mutations

Form is a server component. Submit is a server action. Optimistic UI lives in a small client wrapper around the form. The number of `'use client'` directives stays at one.

Pattern C · Client island for browser-only libs

Map, chart, rich-text editor: dynamically import inside a client component. Wrap with `<Suspense>` so the rest of the page does not wait. The server still renders the surrounding chrome.

Pitfalls we hit (and you will)

  • Putting `'use client'` on the layout because one nav item is interactive · now every page is a client tree. Fix: move the directive down to the smallest interactive piece.
  • Passing a Date or a Map across the boundary and being surprised the client gets a string. The serialisation contract is JSON-ish · stringify + parse mentally before you ship.
  • Using a context provider in a client component but consuming it in a server component. Won't work. Move the consumer to client, or replace context with prop drilling at the boundary.
  • Unbounded async on the server · a slow API blocks the page. Wrap in `<Suspense>` with a real fallback, not a spinner-of-death.
  • Forgetting that `'use client'` does not mean 'render only on the client'. It still pre-renders on the server unless you opt out · the directive marks the boundary, not the runtime.

When neither feels right · refactor the boundary

If you find yourself fighting the boundary, you have probably colocated two things that should be siblings. Split the tree. The pattern: a server component that fetches and lays out, plus a sibling client component that handles the interaction, with `children` or render-prop slots between them. After enough of these, you stop reaching for `'use client'` defensively.

Check your bundle. If a marketing-style page ships more than 80KB of client JS, something is wearing `'use client'` that should not be. Ten minutes with the bundle analyzer almost always finds it.

The summary fits on a Post-it: server by default, promote on a real trigger, keep the client island small, design the boundary like you would design a public API. Two years in, we still mostly delete `'use client'` directives, not add them.

ShareXLinkedIn#
Dezso Mezo
By

Dezso Mezo

Founder, DField Solutions

I've shipped production products from fintech to creator-tooling · for startups and enterprises, from Budapest to San Francisco.

Keep reading
RELATED PROJECTS
Let's talk

Would rather build together?

Let's talk about your project. 30 minutes, no strings.