DEV Community

hinlocaesar
hinlocaesar

Posted on

Server Components vs Client Components: A Mental Model That Actually Sticks

If you've ever pasted useState into a Next.js App Router file and gotten smacked with an error, you've met the Server/Client Component boundary the hard way. Let's build a mental model that makes this click, instead of just memorizing "add 'use client' when it breaks."

Two Computers, One App

Forget React for a second. Every web app you've ever built involves two separate machines doing very different jobs:

  • A server, sitting in a data center somewhere, holding your code and doing the heavy lifting — talking to databases, reading files, running business logic.
  • A client, which is just someone's browser, responsible for painting pixels and reacting to clicks.

These two machines don't share memory. They talk over the network, which is slow and unreliable compared to a function call. That gap between them — the network boundary — is the whole reason this topic exists.

Historically, JavaScript frameworks blurred this line by shipping everything to the browser and letting it sort out what to do. React's Server Components flip that: now you decide, component by component, which machine gets to run the code.

Why Bother Splitting Anything?

Because the two machines are good at different things:

Task Better on... Why
Querying a database Server Direct network access, no API round-trip needed
Handling onClick Client The click literally happens in the browser
Rendering static markup Server Zero JS shipped, faster paint
Managing form input state Client State needs to survive re-renders in the DOM

Push too much to the client and you ship a bloated bundle. Push too much to the server and you lose interactivity. Server/Client Components let you mix both in the same tree, at the granularity of individual components — not whole pages.

A Concrete Example: A Comment Feed

Say you're building a comment section. It needs to:

  1. Fetch existing comments from a database
  2. Let the user type a new comment and hit "Post"

Here's the split:

// CommentFeed.jsx — no directive needed, this is a Server Component by default
async function CommentFeed({ postId }) {
  const comments = await db.comments.findMany({ where: { postId } });

  return (
    <div>
      {comments.map((c) => (
        <p key={c.id}>{c.text}</p>
      ))}
      <NewCommentForm postId={postId} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// NewCommentForm.jsx
'use client';

import { useState } from 'react';

export default function NewCommentForm({ postId }) {
  const [draft, setDraft] = useState('');

  function submit() {
    fetch('/api/comments', {
      method: 'POST',
      body: JSON.stringify({ postId, text: draft }),
    });
    setDraft('');
  }

  return (
    <div>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      <button onClick={submit}>Post</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

CommentFeed never runs in the browser at all — the database query, the await, none of it ships as JS to the client. NewCommentForm is the only piece that needs useState and event handlers, so it's the only piece marked 'use client'.

Notice NewCommentForm is nested inside CommentFeed. That's the important bit: the boundary isn't "this page is server, that page is client" — it's a line you can draw anywhere inside a single component tree.

What Actually Gets Sent Over the Wire

This is the part most explanations skip. The server doesn't render its components into plain HTML and call it done. It produces something closer to a manifest: the finished output of every Server Component, plus marked "slots" saying "a Client Component goes here — here's which JS file to load for it."

The browser receives that manifest, fetches the JS for just the client slots, and stitches the whole thing together into a working page. Your database credentials, your query logic, your server-only imports — none of that text ever reaches the browser's network tab. That's a real security and performance win, not just an academic one.

The Rule of Thumb

You don't need to memorize a flowchart. Ask one question per component:

"Does this need to react to the user, in real time, in the browser?"

  • Reading state, listening to events, using browser-only APIs (localStorage, window), or hooks like useState/useEffectClient Component, add 'use client'.
  • Everything else — data fetching, static layout, anything that doesn't change after the first render → Server Component, which is the default in Next.js's App Router. You don't add anything for this one.

When in doubt, start server-first. Only carve out a client boundary for the smallest piece that truly needs interactivity, the way NewCommentForm above is a small island inside a mostly-static feed. Your bundle size will thank you.

One Gotcha to Save You a Headache

'use client' doesn't mean "this component runs only on the client." It still gets server-rendered for the initial HTML (so users see content immediately), and then it "hydrates" in the browser to become interactive. What the directive really means is: this component's code is allowed to run in the browser, and everything it imports gets bundled for the client too. That's why sprinkling 'use client' at the top of a huge file can accidentally drag a ton of otherwise-server-only code along with it — keep those boundary files small and focused.


Once this framing clicks, the error messages Next.js throws (like trying to use useState in a Server Component) stop feeling like arbitrary rules and start feeling like the framework just reminding you which computer you're talking to.

Top comments (0)