50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 23, 2026·8 min read

Server Components vs Client Components — When to Use Which

The mental model that actually helps you decide which component type to reach for, without flipping a coin every time.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Server Components vs Client Components — When to Use Which

When we first started building with the Next.js App Router, we had a problem: we kept adding 'use client' to everything. Not because we needed to, but because it made the mental overhead go away. Old habits from Pages Router, you know? Then we'd get these weird hydration errors, or wonder why our bundle size was ballooning, and trace it back to some server component deep in the tree that we'd accidentally converted to a client component three weeks ago. The cost of not having a clear mental model isn't obvious at first — and then it compounds.

So here's the model that actually stuck for us, built from real pain. Not theory.

The Default Should Be Server Components

This is the first thing to internalize: in the App Router, every component is a Server Component by default. You don't opt into them — you opt out of them with 'use client'. That's intentional. React and the Next.js team want you to start on the server and only reach for the client when you have a real reason to.

Server Components run on the server at request time (or at build time, depending on your caching setup). They can be async. They can directly fetch from your database or call internal APIs without exposing credentials. They never ship their JavaScript to the browser — which means they don't contribute to your JS bundle. The HTML they produce gets streamed to the client. That's it. No hooks, no event listeners, no browser APIs.

Client Components are what you're used to from React before all this. They run in the browser. They can use useState, useEffect, onClick, window, localStorage — the full browser runtime. But they also get server-rendered as HTML initially (unless you explicitly skip SSR), so 'client component' doesn't mean 'only runs in the browser.' It means 'has a client-side JavaScript bundle.'

The real difference isn't server vs. browser — it's 'do I need interactivity or browser APIs?' If no, stay on the server.

The Actual Decision Tree

We went through probably a dozen mental models before settling on this one. The question to ask is simple but you have to be honest when answering it:

  • Does this component use useState or useReducer? → Client Component
  • Does it use useEffect or any lifecycle hook? → Client Component
  • Does it attach event handlers (onClick, onChange, onSubmit)? → Client Component
  • Does it use browser-only APIs (window, document, navigator, localStorage)? → Client Component
  • Does it use a library that requires the above (like Framer Motion, Recharts, or Radix UI dialogs)? → Client Component
  • Does it just render UI from props or async data, with no interactivity? → Server Component
  • Does it fetch from a database or call a backend service? → Server Component
  • Does it need to keep secrets out of the browser (API keys, internal endpoints)? → Server Component

The catch is that most UI is a mix. A page that fetches data AND has a button that opens a modal. This is where people go wrong — they convert the whole page to a client component when only the modal needs to be.

Push 'use client' Down as Far as Possible

This is the single most important practical rule. When you do need a Client Component, make it as small and leaf-level as possible. The component boundary should wrap only the interactive piece, not the whole page or layout.

Here's a real pattern we use constantly. A product page that needs to fetch data from the database AND has a 'Add to Favorites' button:

// app/products/[id]/page.tsx — Server Component
import { db } from '@/lib/db'
import { FavoriteButton } from '@/components/favorite-button'

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Direct DB access — no API route needed, no credentials leak
  const product = await db.query.products.findFirst({
    where: (p, { eq }) => eq(p.id, params.id),
  })

  if (!product) notFound()

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>${product.price}</p>
      {/* Only this small piece is a Client Component */}
      <FavoriteButton productId={product.id} />
    </div>
  )
}
// components/favorite-button.tsx — Client Component
'use client'

import { useState } from 'react'

export function FavoriteButton({ productId }: { productId: string }) {
  const [favorited, setFavorited] = useState(false)

  const handleClick = async () => {
    setFavorited(!favorited)
    await fetch('/api/favorites', {
      method: 'POST',
      body: JSON.stringify({ productId }),
    })
  }

  return (
    <button onClick={handleClick}>
      {favorited ? '❤️ Saved' : '🤍 Save'}
    </button>
  )
}

The page fetches data on the server (no bundle cost, no waterfall, no exposed credentials), and only the button is a Client Component. If we'd just slapped 'use client' on the page, we'd have lost server-side data fetching, had to introduce an API route just to get the product data, and added the whole component tree to the client bundle. All because of one button.

The 'Passing Children' Pattern — Your Server/Client Bridge

Here's one that takes a bit of getting used to: you can pass Server Components as children to Client Components, and they stay as Server Components. The 'use client' boundary doesn't infect what gets passed through as children or props.

// components/modal-wrapper.tsx — Client Component
'use client'

import { useState } from 'react'

export function ModalWrapper({ trigger, children }: {
  trigger: React.ReactNode
  children: React.ReactNode
}) {
  const [open, setOpen] = useState(false)

  return (
    <>
      <div onClick={() => setOpen(true)}>{trigger}</div>
      {open && (
        <div className="modal">
          <button onClick={() => setOpen(false)}>Close</button>
          {/* children can still be a Server Component! */}
          {children}
        </div>
      )}
    </>
  )
}
// app/dashboard/page.tsx — Server Component using ModalWrapper
import { ModalWrapper } from '@/components/modal-wrapper'
import { db } from '@/lib/db'

export default async function Dashboard() {
  // This still runs on the server, even though ModalWrapper is a Client Component
  const stats = await db.query.stats.findMany()

  return (
    <ModalWrapper trigger={<button>View Stats</button>}>
      {/* This server-fetched content renders inside the modal */}
      <ul>
        {stats.map(s => <li key={s.id}>{s.label}: {s.value}</li>)}
      </ul>
    </ModalWrapper>
  )
}

This pattern is genuinely useful once it clicks. The Client Component controls the open/close state. The content inside the modal can still be server-rendered, server-fetched, no client bundle impact. We use this pattern in almost every complex layout we build.

Common Mistakes We See (and Made)

Beyond the obvious 'just use client everywhere' mistake, there are a few subtler ones:

  • Importing a Client Component library and forgetting the entire importing tree is now client-side. If you import Recharts into a component without 'use client', you'll get an error — but worse, if you do add 'use client', every component that imports it is now a client component too.
  • Trying to pass non-serializable data (functions, class instances, Dates from certain ORMs) as props from Server to Client Components. Props cross the network boundary and get serialized. Stick to plain objects, strings, numbers, arrays.
  • Putting 'use client' at the top of a file that re-exports many components, accidentally making all of them client components when only one needs to be.
  • Using useEffect to fetch data when you could just make the component async and fetch on the server. We did this for months after Pages Router and it's a hard habit to break.
A good sign you've set up the boundary wrong: you're using useEffect to fetch data that doesn't change based on user interaction. That data should be fetched server-side.

When Client Components Are Clearly the Right Call

We don't want to oversell Server Components. There's a whole category of UI that is genuinely client-side by nature, and fighting that is a mistake:

  • Any real-time UI — WebSockets, subscriptions, polling with state updates
  • Complex forms with multi-step validation, dynamic field arrays, or draft saving to localStorage
  • Drag-and-drop interfaces
  • Canvas or WebGL stuff
  • Components that wrap third-party widgets (maps, rich text editors, date pickers)
  • Global state that changes based on user interaction — like a shopping cart, theme toggling, or auth state in the UI

For all of these, just use 'use client' and move on. The goal isn't to have zero client components — it's to not have unnecessary ones eating your bundle and complicating your data flow.

A Real Bundle Size Win

We did an audit on one of our templates a while back. A dashboard that had a sidebar, a data table, a chart, and a bunch of filter controls. Everything was a Client Component because that's how we'd built it originally. After moving the data fetching logic up to proper Server Components and pushing 'use client' down to just the filters and chart, the JS bundle for that route dropped by about 40kb gzipped. Not life-changing, but meaningful — especially on slower mobile connections.

The bigger win was actually in developer experience: no more useEffect fetch chains, no loading spinners for data that's available at render time, and simpler error handling because async/await in Server Components is just... normal JavaScript.

If you're starting a new Next.js project and want to see these patterns applied consistently from the start, our templates at peal.dev are built around this boundary model — every page starts server-side and reaches for 'use client' only when there's an actual reason. It's easier to see it in a real codebase than in a contrived example.

The One-Line Summary

Start with a Server Component. Add 'use client' when you need interactivity or browser APIs. Push that boundary as far down the component tree as you can. Pass server-rendered children through Client Components using the children prop when needed. That's genuinely 90% of the decision-making for most apps.

The remaining 10% is edge cases you'll figure out when you hit them — and when you do, the error messages and React docs are actually pretty good at telling you what went wrong. Unlike two years ago when this was all still experimental and the error messages were just vibes.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders