All insights
Engineering6 min read

Server Components vs Client Components: Making the Right Call

The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.

NC

Nextcraft Engineering Team

The Default Is Your Friend

Every component in the Next.js App Router is a Server Component by default. This is a deliberate decision: most components don't need interactivity, event listeners, or browser APIs. They just need to render data as HTML.

The mistake most teams make is reaching for 'use client' out of habit — because that's how React used to work everywhere. The result is a Client Component tree that negates the performance and SEO benefits of the App Router.

What Server Components Can Do

Server Components run exclusively on the server. They have access to:

  • Direct database queries — no API layer needed
  • File system access — read config files, markdown content, assets
  • Environment variables — including secrets, safely, without exposing them to the browser
  • Server-only packages — heavy libraries that would balloon your client bundle

They render to HTML (or RSC payload for subsequent navigations) and send the result to the browser. Zero JavaScript for the component itself lands on the client.

code
// This runs only on the server — DB credentials never touch the browser
async function ProductList() {
  const products = await db.query('SELECT * FROM products WHERE active = true');
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

What Forces You to Client Components

You need 'use client' when your component uses:

  • useState or useReducer
  • useEffect, useLayoutEffect
  • Browser APIs (window, document, navigator)
  • Event handlers (onClick, onChange, onSubmit)
  • Third-party libraries that rely on any of the above
code
'use client';

import { useState } from 'react';

export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [value, setValue] = useState('');
  return (
    <input
      value={value}
      onChange={e => setValue(e.target.value)}
      onKeyDown={e => e.key === 'Enter' && onSearch(value)}
    />
  );
}

The Composition Pattern

The real skill is composing Server and Client Components together. The key insight: you can pass Server Components as children or props into Client Components.

code
// Server Component — fetches data, has no interactivity
async function DashboardPage() {
  const metrics = await getMetrics();
  return (
    <InteractiveChart>          {/* Client Component */}
      <MetricsSummary data={metrics} />  {/* Server Component as child */}
    </InteractiveChart>
  );
}

The InteractiveChart Client Component can use state and event handlers. The MetricsSummary inside it remains a Server Component — it rendered on the server and was passed as already-rendered RSC payload. This pattern keeps your client bundle minimal.

The "Push Client Down" Heuristic

When you find yourself needing to add 'use client' to a component, ask: can I extract just the interactive part into a smaller component?

Instead of making an entire page a Client Component because it has one button:

code
// Bad — entire page becomes client-rendered
'use client';
export default function ServicePage() {
  const [open, setOpen] = useState(false);
  // ... lots of static content ...
}

Do this:

code
// Good — only the modal trigger is a Client Component
export default function ServicePage() {  // Server Component
  return (
    <main>
      <h1>Our Services</h1>
      <p>Static content rendered on server...</p>
      <ContactModalButton />  {/* Client Component — just the button */}
    </main>
  );
}

Performance Impact

The client bundle size difference is measurable. A typical page built Server-Component-first ships 40–70% less JavaScript than the equivalent built Client-Component-first. This directly affects:

  • Largest Contentful Paint (LCP) — less JS to parse before content is visible
  • Interaction to Next Paint (INP) — the main thread is less congested
  • Time to Interactive (TTI) — the page becomes usable sooner

For mobile users on throttled connections, this isn't an academic concern. It's the difference between a 2-second and a 4-second load.

Common Mistakes

Mistake 1: Putting context providers in Server Components. Context is a client-side concept. Wrap your app in a Client Component provider at the layout level, but keep everything inside it as Server Components where possible.

Mistake 2: Importing a Client Component library in a Server Component. If a package uses useState internally and you import it in a Server Component, you'll get an error. The fix is to wrap it in a thin 'use client' component.

Mistake 3: Fetching in Client Components when you don't need to. useEffect + fetch in a Client Component gives you a waterfall: render → mount → fetch → re-render. A Server Component with await fetch() gives you data before the first render.

The Decision Framework

Ask these questions in order:

  1. Does this component use state, effects, or event handlers? → Client Component
  2. Does it use browser APIs? → Client Component
  3. Does it use a library that requires client-side runtime? → Client Component
  4. Otherwise → Server Component

Default to Server. Opt into Client when you have a concrete reason. Your users' devices will thank you.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.