The textbook rule for the App Router is short: server components by default, and 'use client' when you need state, effects, or browser APIs. It's correct, and on its own it still leads to a specific mess. Half the tree ends up as client components because one button somewhere needed an onClick.

The mistake isn't picking the wrong side for a component. It's drawing the boundary too high in the tree.

The boundary is contagious, downward

'use client' doesn't just mark one component. It marks a boundary: everything that component imports comes along into the client bundle. Put it on a page-level layout because the header has a dropdown, and you've shipped the whole page's rendering logic to the browser, along with any heavy libraries those components import.

So the first question I ask isn't "is this interactive?" It's:

What's the smallest piece of this that's actually interactive?

A product card with a "save" button isn't a client component. It's a server component that renders a small client SaveButton:

// ProductCard.tsx — server component, no directive
import { SaveButton } from './save-button'
 
export function ProductCard({ product }: { product: Product }) {
  return (
    <article>
      <h3>{product.name}</h3>
      <p>{formatPrice(product.price)}</p>
      <SaveButton productId={product.id} />
    </article>
  )
}
// save-button.tsx
'use client'
 
export function SaveButton({ productId }: { productId: string }) {
  const [saved, setSaved] = useState(false)
  // ...
}

The card, the price formatting, and whatever date or markdown library sits behind it all stay on the server. Only the button ships JavaScript.

Pass server components through client ones

The other trick is that a client component can't import a server component, but it can render one passed in as children or another prop. That's how a client-side wrapper like a tab panel, modal, or collapsible section can hold server-rendered content:

// page.tsx — server
<Collapsible title='Booking history'>
  <BookingHistory userId={user.id} /> {/* server component, fetches its own data */}
</Collapsible>

Collapsible owns the open/closed state on the client. BookingHistory still runs on the server, queries the database directly, and arrives as already-rendered output. Once this clicked for me, most of my "this whole section has to be a client component" cases went away.

The questions, in order

When I'm deciding where something runs, this is the actual checklist:

  1. Does it use state, effects, event handlers, or browser APIs? If no, it's a server component. Done.
  2. If yes, what's the smallest subtree that needs them? Push 'use client' down to that.
  3. Does that client piece need server-rendered content inside it? Pass it as children instead of importing it.
  4. Is it receiving data it shouldn't expose? Props crossing into a client component get serialized into the page. Pass the three fields the button needs, not the whole user record.

That last one gets missed a lot. It's easy to hand a client component an entire database row "for convenience" and ship fields to the browser that were never meant to leave the server.

Where I go client-side on purpose

Highly interactive surfaces — a map with live vehicle positions, a booking form with dependent fields, anything with drag and drop — are client components from the top, and that's fine. Fighting to keep server boundaries inside something that's interactive all the way through just gives you awkward prop threading for no bundle-size win.

The trade-off

Small client islands mean more files and more thought about where each boundary sits, compared to slapping 'use client' on the page and moving on. On a small internal tool behind a login, where bundle size barely matters, I'm less strict about it. On anything public and content-heavy, where load time affects both users and search ranking, the boundary placement is most of the performance story. I'd rather get it right while building than fix it later.