Start a Project
All posts
DevelopmentApril 14, 20268 min read

Server Components vs Client Components: When to Use Each

A practitioner guide to React Server Components and Client Components in Next.js: the mental model, the client boundary pattern, and the mistakes to avoid.

React Server Components are no longer the new thing. They are the default way Next.js apps are built, and we have shipped enough of them now to have strong opinions about the mistakes that keep recurring. This post is the mental model we hand new engineers when they join a Server Components codebase, plus the patterns we reach for once they have the basics.

The mental model: server by default, client on the leaves

The single sentence version: render on the server, opt into the client only where you need interactivity. That sounds obvious. It is not how most teams actually structure their apps, because muscle memory from the SPA era pushes you to make everything a client component.

A useful way to think about it: your component tree is a server tree with islands of client components. The islands should be as small as possible and as deep in the tree as possible. The further you push 'use client' toward the leaves, the more of your app gets the server component benefits: zero client JS for that code, direct data access, faster initial render.

What server components can do that client components cannot

Server components run once, on the server, and return serialized output. That gives you four superpowers:

  1. Async by default. A server component can be an async function. You can await fetch, await db.query, await getSession directly in the component body.
  2. Direct access to server resources. Database connections, file system, secrets in environment variables, internal services on a private network.
  3. No client bundle cost. The component's code never ships to the browser. A 200KB Markdown parser used only at render time costs zero kilobytes on the client.
  4. No hydration cost. The output is HTML. No JS needs to run to make it interactive, because it is not interactive.
// app/posts/[slug]/page.tsx
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import { LikeButton } from "./like-button";

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await db.post.findUnique({ where: { slug: params.slug } });
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
      <LikeButton postId={post.id} initialCount={post.likeCount} />
    </article>
  );
}

That entire page renders on the server. The only JS that ships to the browser is whatever LikeButton needs.

What client components are for

Client components are not "the bad ones." They are the right tool for anything that needs to react to the user or the browser:

  • useState, useReducer, useContext, any hook with state
  • useEffect and lifecycle behavior
  • Event handlers (onClick, onChange, onSubmit)
  • Browser APIs (window, localStorage, IntersectionObserver, drag and drop)
  • Third party libraries that touch the DOM directly
// app/posts/[slug]/like-button.tsx
"use client";

import { useState, useTransition } from "react";

export function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [pending, start] = useTransition();

  return (
    <button
      disabled={pending}
      onClick={() => {
        start(async () => {
          setCount((c) => c + 1);
          await fetch(`/api/posts/${postId}/like`, { method: "POST" });
        });
      }}
    >
      Like ({count})
    </button>
  );
}

The 'use client' directive marks the file as a client entry point. Everything imported from this file is also bundled for the client. That is the rule worth tattooing.

The client boundary pattern

The trick that takes a Server Components app from "fine" to "fast" is keeping the client subtree small by passing server rendered content into a client wrapper as children. A client component can render children without those children becoming client components themselves.

// app/dashboard/page.tsx
import { Sidebar } from "@/components/sidebar";
import { ExpensiveServerComponent } from "@/components/expensive";
import { CollapsiblePanel } from "@/components/collapsible-panel";

export default function Dashboard() {
  return (
    <CollapsiblePanel> {/* client component, holds open/closed state */}
      <ExpensiveServerComponent /> {/* still a server component */}
    </CollapsiblePanel>
  );
}

CollapsiblePanel owns the open/closed state and the toggle handler. ExpensiveServerComponent renders on the server and is passed in as a child. The client component never re-renders the server content because it is already a fully formed React element by the time it arrives.

This pattern unlocks a lot. It is how you put a server rendered list inside a client side filter UI, a server rendered article inside a client side reading progress tracker, or a server fetched dashboard inside a client controlled tab.

The mistakes we keep seeing

After a few dozen RSC code reviews, the same problems show up:

  • Making everything a client component. Adding 'use client' to your layout or root page pulls the entire tree into the client bundle. You lose every benefit of server components. Use 'use client' at the smallest reasonable scope.
  • Forgetting 'use client' entirely. You get a confusing error about hooks or event handlers in server components. The fix is the directive, not refactoring the component.
  • Passing functions across the boundary. Server components cannot pass functions as props to client components (except server actions). The serialization barrier is real. Pass primitive data and let the client component define its own handlers.
  • Importing client only libraries in server components. Some libraries reach for window at import time. Wrapping their usage in a client component fixes it.
  • Putting secrets in components that might leak. Anything in a server component is safe. But once you pass it as a prop to a client component, it crosses the network in the RSC payload. Treat that boundary as a public API.

When to break the rule

Defaults are defaults, not laws. We reach for client components more aggressively in three cases:

  1. Highly interactive surfaces. A rich text editor, a Kanban board, a chart with drilldowns. The interactivity is the product. Server rendering the shell and hydrating into a client app is the right shape.
  2. Streaming with Suspense. A server component can stream, but if you need fine grained loading states tied to user actions, a client component with a transition is often simpler.
  3. Client side data that updates frequently. Live presence indicators, real time collaboration, anything driven by WebSockets. The data lives on the client. Fetch it there.

Practical takeaways

  • Start every component as a server component. Add 'use client' only when something inside it demands it.
  • Push the client boundary as deep as possible. Wrap interactive bits, do not wrap the whole page.
  • Use the children pattern to keep server rendered content out of the client bundle even when it is visually inside an interactive container.
  • Treat the props crossing the boundary as a public API. Serialize only what is needed, never secrets.
  • Measure the client bundle. A surprise spike usually means a 'use client' crept too high in the tree.

We build production Next.js apps for clients every month, and the apps that age well are the ones that respect the boundary discipline from day one. If you are migrating an older Next.js app to App Router or starting a new project and want to skip the early mistakes, let us know. We have shipped this and we have opinions.

Tags

React Server ComponentsClient ComponentsNext.js App Routeruse client directiveclient boundary patternRSC data fetchingNext.js performanceReact 19

Ready to Build Something Great?

Let's discuss your project and explore how we can help you achieve your digital goals. From concept to launch, we're here to make it happen.

One business-day reply

Initial scoping call within the week.

No-cost project audit

We start by understanding what you actually need.

Transparent pricing

Fixed scope, retainer, or team-extension. Your call.