Server Components vs Client Components: A Practical Comparison

The "use client" directive gets treated as the whole story, but it’s a boundary marker, not the mechanism. The actual question when splitting a component tree is: where does this code need to run, and what does it need access to while running there?

Capability Server Component Client Component
Fetch data from a database directly Yes, in a server environment Not directly from browser code — needs a server endpoint or Server Function
Use useState/useEffect No Yes
Handle browser events No Yes
Access secrets securely Yes, when kept server-side No — secrets must never be bundled into client code
Use browser APIs No Yes, with appropriate runtime checks
Reduce client-side JavaScript Yes Adds JavaScript when its code is needed in the client bundle
Stream rendered output Supported through compatible frameworks Participates in framework rendering and hydration

What a Server Component can do that a Client Component can’t

Server Components render on the server, and their output — not their code — is what reaches the browser. That means they can:

  • Query a database or call an internal API directly, with no client-exposed endpoint needed.
  • Read secrets and environment variables safely, since none of that code ships to the browser.
  • Keep large dependencies (a markdown parser, a PDF library) out of the client bundle entirely.
// app/posts/[slug]/page.jsx — Server Component by default
import { db } from "@/lib/db";
import { notFound } from "next/navigation";

export default async function PostPage({ params }) {
  const { slug } = await params; // dynamic route params are async in the current App Router API

  const post = await db.posts.findUnique({ where: { slug } });
  if (!post) notFound();

  return <article>{post.body}</article>; // sanitize first if post.body is raw HTML
}

There’s no useEffect, no loading spinner, no client-side fetch waterfall for this data — it’s resolved on the server as part of rendering. That doesn’t mean the response is simple one-shot static HTML, though: a framework like Next.js can still stream the output, and Client Components can be part of that same initial server-rendered HTML before they hydrate in the browser.

What only a Client Component can do

Client Components run in the browser and can use the things that require a browser: state, effects, event handlers, and browser-only APIs.

"use client";

import { useState } from "react";

export default function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false);
  const [pending, setPending] = useState(false);

  async function handleLike() {
    if (pending) return;
    setPending(true);
    try {
      const response = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
      if (!response.ok) throw new Error("Failed to update like");
      setLiked(current => !current);
    } catch (error) {
      console.error(error);
    } finally {
      setPending(false);
    }
  }

  return (
    <button onClick={handleLike} disabled={pending}>
      {liked ? "♥" : "♡"}
    </button>
  );
}

Still illustrative — a production version would derive the initial liked state from the real like status and have the server authenticate/authorize the request and handle duplicates. But note it now actually checks the response and reflects a failure, rather than optimistically flipping the UI and ignoring whether the server accepted it.

Using useState, useEffect, a click handler, window, or localStorage anywhere in a file is the practical signal that it needs "use client". More precisely, the directive marks a client module entry point — not every individual file that happens to reference client-side behavior — and once marked, browser APIs still need the usual runtime guards (e.g. checking typeof window !== "undefined"), since a Client Component is still rendered on the server for the initial HTML before it hydrates in the browser.

The part that actually trips people up

A Client Component can’t import a Server Component and expect it to execute on the server — anything a Client Component imports enters the client module graph, along with its transitive dependencies, and ships to the browser. That’s a statement about the module dependency graph, though, not about JSX nesting in general — a Server Component parent can still render another Server Component and hand its already-rendered output down to a Client Component via children (or another prop):

// app/page.jsx — Server Component
import ClientWrapper from "./ClientWrapper";
import ServerRenderedChild from "./ServerRenderedChild";

export default function Page() {
  return (
    <ClientWrapper>
      <ServerRenderedChild />
    </ClientWrapper>
  );
}
// app/ClientWrapper.jsx
"use client";

import { useState } from "react";

export default function ClientWrapper({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <section>
      <button onClick={() => setOpen(value => !value)}>Toggle content</button>
      {open && children}
    </section>
  );
}

Page (a Server Component) renders ServerRenderedChild on the server and passes the result into ClientWrapper as children. ClientWrapper controls whether that content is shown, but it never turns that content into client-side code — it only works because ClientWrapper.jsx itself never imports ServerRenderedChild; it only receives the already-rendered result through a prop.

What actually crosses the boundary: the RSC Payload

When a Server Component’s output needs to reach a Client Component (or the browser), it travels as the React Server Component Payload — a serialized format carrying the rendered Server Component output, references to which Client Components are needed and their JavaScript, and any props passed across the boundary. This is the mechanism underneath everything above: it’s why a Server Component’s code never ships to the browser (only its rendered result does), and why a Client Component’s own module and its imports do ship (they’re referenced in the payload as code the browser needs to run).

A rough rule of thumb

Default to Server Components for anything that’s just fetching and displaying data. Push the "use client" boundary down to the smallest possible leaf — the button, the form, the toggle — rather than marking an entire page client just because one widget on it needs useState. A Client Component’s module and its own imports contribute to the client bundle; server-rendered content merely passed in as a prop doesn’t get converted into client code by virtue of being nested inside one. Keeping the client module graph small is still the goal — it’s just not as simple as “everything inside is client.”

See also: React useEffect Cleanup: When and Why It Matters for effect behavior once inside a Client Component boundary, and 10 Online Code Playgrounds for React, Python & More for a sandbox to test the split directly.

Leave a Comment

Your email address will not be published. Required fields are marked *