Skip to content
digital garden
Back to Blog

Server Components vs. Client Components — A Decision Framework

11 min read
nextjsreactarchitectureperformance

Every few months I see the same debate: "Should this be a Server Component or a Client Component?" The community over-corrected hard. We went from "everything is a client component" in the Pages Router era to "everything must be a server component or you're doing it wrong" with App Router. Neither extreme is right.

The truth is simpler than the discourse suggests. Server and Client Components solve different problems. The goal isn't to minimize "use client" directives — it's to put the right code in the right place. Here's the framework I use to make that call on every component I write.

The Mental Model

Before the decision tree, you need to understand what actually happens at build and request time. This is where most confusion starts.

Server Components run on the server (or at build time for static pages). They produce a serialized React tree — called the RSC payload — that gets sent to the client. The browser never downloads the component's JavaScript. It just receives the rendered output.

Client Components are pre-rendered on the server too (this is what trips people up), but their JavaScript bundle is also sent to the client for hydration. The browser downloads, parses, and executes the component code so it can attach event handlers and manage state.

Server Component:
  Server: Execute ──▶ Serialize to RSC payload
  Client: Render HTML from payload (no JS downloaded)

Client Component:
  Server: Pre-render (SSR) ──▶ Serialize payload + JS bundle ref
  Client: Render HTML ──▶ Download JS ──▶ Hydrate (attach handlers)

The key insight: Client Components aren't "client-only." They render on both server and client. The "use client" directive means "this component needs to hydrate" — not "skip the server entirely."

The Decision Tree

When I'm building a new component, I run through this:

Does this component need...
│
├─ useState, useEffect, useRef, or other hooks?
│  └─▶ Client Component
│
├─ Event handlers (onClick, onChange, onSubmit)?
│  └─▶ Client Component
│
├─ Browser APIs (window, document, localStorage)?
│  └─▶ Client Component
│
├─ Direct database/API access with secrets?
│  └─▶ Server Component
│
├─ Access to server-only resources (fs, env vars)?
│  └─▶ Server Component
│
├─ Heavy dependencies (markdown parsers, date libs)?
│  └─▶ Server Component (keeps them out of the bundle)
│
├─ None of the above — just renders props/children?
│  └─▶ Server Component (it's the default, keep it)
│
└─ Both interactive AND data-heavy?
   └─▶ Split it. Server parent + Client child.

The last case is the most common in real apps, and it's where the composition pattern becomes essential.

Common Patterns

1. Data Fetching — Server Component

This is the most straightforward case. If a component fetches data, it should almost always be a Server Component. No useEffect, no loading states to manage, no client-side waterfall.

// app/dashboard/page.tsx — Server Component (no directive needed)
import { db } from "@/lib/db";
import { RevenueChart } from "./revenue-chart";
 
export default async function DashboardPage() {
  const metrics = await db.query.metrics.findMany({
    where: (m, { gte }) => gte(m.date, thirtyDaysAgo()),
    orderBy: (m, { asc }) => asc(m.date),
  });
 
  const dataPoints = metrics.map((m) => ({ date: m.date, value: m.revenue }));
  const total = metrics.reduce((sum, m) => sum + m.revenue, 0);
 
  return (
    <section>
      <h2>Revenue — Last 30 Days</h2>
      <RevenueChart data={dataPoints} />
      <p>Total: ${total.toLocaleString()}</p>
    </section>
  );
}

The database query runs on the server. The heavy computation stays on the server. Only the chart — which needs canvas/SVG interactivity — is a Client Component.

2. Interactive Forms — Client Component

Forms with validation, optimistic updates, or multi-step flows belong on the client. Server Actions handle the submission, but the interaction logic lives in a Client Component.

"use client";
 
import { useActionState } from "react";
import { updateProfile } from "@/app/actions/profile";
 
export function ProfileForm({ user }: { user: User }) {
  const [state, formAction, isPending] = useActionState(updateProfile, {
    errors: {},
  });
 
  return (
    <form action={formAction}>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        name="name"
        defaultValue={user.name}
        aria-describedby={state.errors.name ? "name-error" : undefined}
      />
      {state.errors.name && (
        <p id="name-error" role="alert">{state.errors.name}</p>
      )}
 
      <label htmlFor="bio">Bio</label>
      <textarea id="bio" name="bio" defaultValue={user.bio ?? ""} rows={4} />
 
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save Changes"}
      </button>
    </form>
  );
}

3. The Composition Pattern — Server Parent, Client Child

This is the pattern I use most. The server component fetches data and handles layout. The client component handles a specific interactive slice.

// app/products/[id]/page.tsx — Server Component
import { getProduct } from "@/lib/products";
import { AddToCartButton } from "./add-to-cart-button";
import { ImageGallery } from "./image-gallery";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
 
  return (
    <article>
      {/* Client: needs swipe gestures and zoom */}
      <ImageGallery images={product.images} />
 
      <div>
        <h2>{product.name}</h2>
        <p>{product.description}</p>
        <p>${product.price}</p>
        {/* Client: needs onClick + cart state */}
        <AddToCartButton productId={product.id} price={product.price} />
      </div>
    </article>
  );
}

The page is a Server Component. The gallery and cart button are Client Components. The product name, description, and price render as static HTML with zero client-side JS.

4. Search and Filter with URL State

Search UIs need interactivity (typing, debouncing) but the results should be server-rendered. Use useSearchParams in a Client Component for the input, and let the Server Component page read the params and fetch.

// app/blog/search-input.tsx
"use client";
 
import { useSearchParams, useRouter } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
 
export function SearchInput() {
  const searchParams = useSearchParams();
  const router = useRouter();
 
  const handleSearch = useDebouncedCallback((query: string) => {
    const params = new URLSearchParams(searchParams.toString());
    if (query) {
      params.set("q", query);
    } else {
      params.delete("q");
    }
    router.replace(`/blog?${params.toString()}`);
  }, 300);
 
  return (
    <input
      type="search"
      placeholder="Search posts..."
      defaultValue={searchParams.get("q") ?? ""}
      onChange={(e) => handleSearch(e.target.value)}
    />
  );
}
// app/blog/page.tsx — Server Component
import { getPosts } from "@/lib/posts";
import { SearchInput } from "./search-input";
 
export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  const posts = await getPosts({ search: q });
 
  return (
    <section>
      <SearchInput />
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <a href={`/blog/${post.slug}`}>{post.title}</a>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}

The search input is a thin Client Component. The actual filtering and rendering is server-side. The URL is the source of truth, which means the search state is shareable and bookmarkable.

5. Auth-Gated Content

Check auth on the server. Don't ship auth-checking logic to the client if you don't need to.

// app/settings/page.tsx — Server Component
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { SettingsForm } from "./settings-form";
 
export default async function SettingsPage() {
  const session = await auth();
 
  if (!session) {
    redirect("/login");
  }
 
  return (
    <section>
      <h2>Account Settings</h2>
      {/* Pass only what the client needs — not the full session */}
      <SettingsForm
        name={session.user.name}
        email={session.user.email}
        plan={session.user.plan}
      />
    </section>
  );
}

The auth check happens on the server before any HTML is sent. No flash of unauthorized content. No client-side redirect. The SettingsForm is a Client Component because it handles form interactions, but it receives only the data it needs — not the raw session token.

Performance Comparison

Here's how the two types compare across the metrics that actually matter:

MetricServer ComponentClient Component
JS Bundle Size0 KB added to clientFull component code shipped
Time to InteractiveInstant (no hydration)Delayed by hydration
StreamingCan stream as resolvedWaits for full hydration
Initial RenderServer-rendered HTMLServer-rendered HTML (same)
Subsequent NavRSC payload fetchedAlready hydrated, instant
Data FreshnessFresh on every requestStale until revalidation
Memory (client)MinimalComponent state in memory

The biggest win for Server Components is bundle size. A component that imports date-fns, marked, and sanitize-html to render a blog post adds zero bytes to the client bundle as a Server Component. As a Client Component, that's ~80KB gzipped your users download and parse.

The biggest win for Client Components is subsequent navigations. Once hydrated, a client component handles interactions instantly without a server round-trip. For highly interactive UIs — think drag-and-drop, real-time collaboration, or complex form wizards — this matters more than the initial load cost.

Common Mistakes

Mistake 1: Marking an entire page as a Client Component

// ❌ Wrong — makes EVERYTHING a client component
"use client";
 
export default async function ProductsPage() {
  // This won't even work — async components
  // can't be Client Components
  const products = await getProducts();
  return <ProductList products={products} />;
}
// ✅ Right — server page with client islands
// app/products/page.tsx (Server Component)
import { getProducts } from "@/lib/products";
import { ProductFilters } from "./product-filters"; // "use client"
 
export default async function ProductsPage() {
  const products = await getProducts();
 
  return (
    <section>
      <ProductFilters />
      <ul>
        {products.map((p) => (
          <li key={p.id}>{p.name} — ${p.price}</li>
        ))}
      </ul>
    </section>
  );
}

Mistake 2: Fetching data in a Client Component with useEffect

// ❌ Wrong — client-side waterfall, loading spinner, no SEO
"use client";
 
import { useEffect, useState } from "react";
 
export function PostContent({ slug }: { slug: string }) {
  const [post, setPost] = useState<Post | null>(null);
 
  useEffect(() => {
    fetch(`/api/posts/${slug}`).then((r) => r.json()).then(setPost);
  }, [slug]);
 
  if (!post) return <div>Loading...</div>;
  return <article>{post.content}</article>;
}
// ✅ Right — server component, no loading state, SEO-friendly
// app/blog/[slug]/page.tsx
import { getPost } from "@/lib/posts";
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
 
  return (
    <article>
      <h2>{post.title}</h2>
      <time dateTime={post.date}>{post.formattedDate}</time>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

Mistake 3: Passing non-serializable props across the boundary

// ❌ Wrong — functions can't cross the server/client boundary
// Server Component
export default function Page() {
  const handleClick = () => console.log("clicked");
  return <ClientButton onClick={handleClick} />;
}
// ✅ Right — use Server Actions for server-side logic
// Server Component
import { likePost } from "@/app/actions/posts";
 
export default function Page({ postId }: { postId: string }) {
  const likeThisPost = likePost.bind(null, postId);
  return <LikeButton action={likeThisPost} />;
}
// like-button.tsx
"use client";
 
export function LikeButton({ action }: { action: () => Promise<void> }) {
  return (
    <form action={action}>
      <button type="submit">Like</button>
    </form>
  );
}

Mistake 4: Using "use client" on a component that doesn't need it

// ❌ Wrong — this component has no interactivity
"use client";
 
export function UserAvatar({ name, src }: { name: string; src: string }) {
  return <img src={src} alt={name} />;
}
// ✅ Right — leave off the directive, it's a Server Component by default
import Image from "next/image";
 
export function UserAvatar({ name, src }: { name: string; src: string }) {
  return <Image src={src} alt={name} width={40} height={40} />;
}

Every unnecessary "use client" directive adds that component and all its imports to the client bundle. Death by a thousand cuts.

The Hybrid Approach — Structuring a Real Page

Here's how I structure a typical page in a production app. The page itself is a Server Component. Interactive pieces are pushed to the smallest possible Client Components at the leaves.

app/projects/[id]/page.tsx          ← Server Component (data fetching, layout)
├── components/
│   ├── project-header.tsx          ← Server Component (static display)
│   ├── project-tabs.tsx            ← Client Component (tab switching)
│   ├── project-activity-feed.tsx   ← Server Component (renders list)
│   │   └── activity-item.tsx       ← Server Component (static)
│   ├── project-comment-form.tsx    ← Client Component (form interaction)
│   └── project-share-button.tsx    ← Client Component (clipboard API)

The ratio is typically 60-70% Server Components, 30-40% Client Components. If you find yourself with more Client Components than Server Components, step back and check whether you're reaching for "use client" out of habit.

The rule of thumb: start every component as a Server Component. Add "use client" only when the compiler tells you to (because you used hooks or event handlers) or when you genuinely need browser APIs. Don't guess. Don't preemptively add it. Let the actual requirements of the component drive the decision.

If you're building with the composition patterns I covered above, you'll find that most of your app stays on the server — and the parts that don't are small, focused, and easy to reason about. That's the goal. Not zero client components. Not maximum server components. Just the right code in the right place.

Comments

No comments yet. Be the first to comment!