Skip to content
React Server Components, Explained Like You'll Actually Use Them
Milos KnezevicMilos Knezevic20 Oct 2026 · 12 min read

React Server Components, Explained Like You'll Actually Use Them

A server component runs once, on the server, and ships its output instead of JavaScript. What that really means, when to use one, and where it will hurt.

If you have been writing React for a few years, your instincts were trained by a world where every component is a function the browser downloads, runs, and re-runs. Server components break that assumption, which is why they feel wrong before they feel obvious. This is the explanation I wish someone had handed me: what a server component actually is, how it differs from SSR, where the 'use client' boundary really sits, and what the error messages mean — ending with a placement rule for every component you write this week.

Server components are also the foundation the rest of the modern stack is built on — data fetching, caching, and streaming all changed shape around them. For the wider map of how the pieces fit together, the full-stack overview covers it; this article goes deep on the one piece almost everyone gets wrong.

A server component runs once, on the server, and ships no JavaScript

Every React component you wrote before server components had the same life cycle: its source was bundled, the bundle downloaded, the function ran in the browser to produce UI, and then it kept running — re-rendering on every state change for as long as the tab stayed open. A living thing in the browser's memory.

A server component is not that. It is a function that runs once, on the server, while the request is being handled. It produces its output — HTML plus a structured description of the UI called the RSC payload — and that output is what travels over the network. The function itself never leaves the server. The browser receives the result of the component, never the component.

This is why the rules that look arbitrary are not. A server component cannot use useState, because there is no second render in which state could change — it runs once and is done. It cannot use useEffect, because effects synchronize a living component with the outside world, and a server component is not alive. It cannot attach an onClick, because by the time a user clicks, the function that rendered the button no longer exists anywhere.

In exchange, a server component can do things browser code never could. It can be an async function and await its data. It can query your database directly, read the filesystem, use secret API keys, and import heavy libraries — a markdown parser, a syntax highlighter, a date library with every locale — without a byte of that weight reaching the user. The cost of a dependency in a server component is measured in server CPU, not in your visitors' download time.

Server components are not SSR — the difference is hydration

The two ideas sound identical — React rendering on the server — but they are different mechanisms solving different problems, and the difference is hydration.

Server-side rendering, the kind Next.js has done for years, runs a client component on the server to produce HTML for the first paint. But the component's JavaScript still downloads, and React then hydrates: it runs the same component again in the browser, rebuilds the tree in memory, and attaches event listeners to the HTML already on screen. SSR is a head start, not a diet. You render twice and ship the code anyway.

A server component never hydrates. It runs exactly once, on the server, and the browser never re-runs it because the browser never has it. SSR answers the question 'how do I make client components paint faster'. Server components answer a different one: 'why is this code in the browser at all'.

The two compose rather than compete: in the App Router, your client components are still server-rendered for the first paint and then hydrate, while the server components around them stay inert HTML forever. That composition is a large part of why the App Router exists at all; the App Router article walks through the architecture decision in full.

'use client' marks a door, not a file type

The directive's name does real damage here. 'use client' reads like an annotation on one file, but what it actually declares is a boundary in the module graph: this file, everything it imports, and everything those files import is client code. One directive at the top of a layout can silently drag your entire page tree into the bundle.

The first time your bundle size jumps after a one-line change, this is usually why. You added 'use client' near the root — maybe for a mobile-menu hook — and every component imported below it stopped being a server component. The directive does not mark a component; it marks a door, and everything that walks through the door becomes client code.

The composition rules fall out of the boundary model. A server component can import and render a client component freely — the server just leaves a placeholder in its output saying 'a client component mounts here, with these props'. A client component cannot import a server component, because importing means bundling, and a server component's code must never be bundled. But — and this is the escape hatch that keeps real apps sane — a client component can receive server-rendered content as children.

app/layout.tsx
import { ThemeProvider } from '@/components/theme-provider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

ThemeProvider is client code — it needs context and state. But children was rendered on the server before ThemeProvider ever ran, and passes through as already-finished output. The client component wraps the server content the way a picture frame wraps a photograph: it holds it without repainting it. This slot pattern is the one most people miss, and it is how providers, animation wrappers, and layout shells stay on the client without converting everything inside them.

tip

When a client component needs to contain server-rendered content, pass it as children or props instead of importing it. Imports cross the boundary and drag code with them; props carry finished output across it.

The boundary takes one more thing with it: React context. A server component cannot read context, because context is a feature of the living tree and the server tree is not alive. Anything you used to thread through a provider — theme, locale, the current user — needs a different path on the server: read it from cookies or headers, or pass it down as props. Your providers still work, but only for the client subtrees beneath them.

When to use server components: a decision framework

The test I apply resolves almost every case in seconds: does this component need to do something after the page has loaded? If a user interaction or a browser event must run code, it is a client component. If the component's only job is to take data and turn it into UI, it is a server component.

  • Client component: onClick, onChange, useState, useEffect, refs on DOM nodes, browser APIs like localStorage or IntersectionObserver, any third-party hook.
  • Server component: fetching and displaying data, rendering markdown or CMS content, formatting dates and prices, anything that touches your database, secrets, or filesystem.
  • Default: server — add 'use client' only when the compiler or a runtime error forces you to, never speculatively.

The other half of the framework: push the boundaries down to the leaves. A blog post page is a server component — it fetches the post, renders the markdown, lays out the header. The like button inside it is a client component: a few lines of state and a click handler. The page ships kilobytes of HTML and one tiny island of JavaScript, instead of shipping everything because one button needed a hook.

And when a component does need hands, write the client component without guilt. Forms are the clearest case: a signup form that validates as the user types, disables submit while the request is in flight, and shows the error next to the field that caused it is a living component doing exactly what client components exist for. So are search inputs, drag handles, charts that respond to hover. The goal was never zero JavaScript — the goal is that every kilobyte you ship does a job only the browser can do.

This also reframes state management. Global client state does not disappear, but it shrinks to the client subtrees that genuinely need it — and a lot of what you used to put in a store was really server data in disguise. The state management comparison has a section on exactly how server components changed that calculus.

One honest caveat: there is a class of app where the wins shrink. A dashboard behind a login, where every view is personal and interactive, leaves little read-only content to keep on the server, and you may end up with 'use client' near the root anyway. Server components reward content-heavy, read-mostly pages the most — which is, to be fair, most of the web.

One page, refactored — where data fetching lives now

Theory holds better with a before and after. Here is the page every React developer has written — fetch on mount, spinner while loading, render the list:

app/posts/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { Spinner } from '@/components/ui/spinner';
import type { Post } from '@/types';

export default function PostsPage() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/posts')
      .then((res) => res.json())
      .then((data) => setPosts(data.posts))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Look at the cost. The user downloads the component code, React runs it, the effect fires, and a request goes back to the server the user just came from. Two round trips before anything useful appears, and the first paint is a spinner. You also maintain /api/posts, an endpoint whose only consumer is your own page.

The server component version:

app/posts/page.tsx
import { prisma } from '@/lib/prisma';
import { LikeButton } from './like-button';

export default async function PostsPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          <LikeButton postId={post.id} initialCount={post.likes} />
        </li>
      ))}
    </ul>
  );
}
app/posts/like-button.tsx
'use client';

import { useState } from 'react';

type Props = { postId: string; initialCount: number };

export function LikeButton({ postId, initialCount }: Props) {
  const [count, setCount] = useState(initialCount);

  async function like() {
    setCount((c) => c + 1);
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  }

  return <button onClick={like}>Like ({count})</button>;
}

The page is now an async function that awaits the database directly. There is no loading state, because the data exists before the HTML is sent — the first paint is the content. The Prisma client, the query, and your table schema never come near the browser. The list endpoint is gone. And the one piece of genuine interactivity, the like button, is a small client island that ships exactly the JavaScript it needs.

warning

Props that cross from a server component to a client component travel as data over a real network gap, so they must survive serialization: plain objects, arrays, strings, numbers, dates. Class instances do not — pass a Prisma Decimal or a Day.js object and you get 'Only plain objects can be passed to Client Components'. Convert to primitives at the boundary.

The three errors you will hit, decoded

React's boundary errors are accurate and unhelpful in equal measure: they name the rule you broke without naming the decision that was wrong. Here are the three you will meet first, and what each is actually saying.

'useState only works in a Client Component'

The full message even suggests its own fix: mark the file, or its parent, with 'use client'. What it means: the file defaulted to server, because every file does, and a hook turned up where no living component exists. The trap is the phrase 'or its parent'. Adding the directive to a parent makes the error disappear and quietly converts an entire subtree to client code. Before adding it anywhere, ask whether the state belongs in this component at all — usually the right fix is to extract the stateful part into a small leaf and mark only that.

'Event handlers cannot be passed to Client Component props'

You put an onClick on an element inside a server component, or passed a callback as a prop across the boundary. It cannot work for the serialization reason above: a function is not data, and by the time the user clicks, the server function that rendered the page is gone. The fix depends on what the function does. Browser work — toggling, navigating, animating — moves into a client component together with its element. Server work — deleting a row, sending an email — becomes a Server Action: mark it with 'use server' and it turns into the one kind of function that can cross the boundary, as a reference the client can call back. The sibling message, 'Functions cannot be passed directly to Client Components', is the same rule caught at a different door.

'createContext is not a function' — thrown from node_modules

This one looks like a broken package, because the stack trace points into node_modules and the code that crashed is not yours. It is the ecosystem tax. A decade of React libraries assumed every component is client code, and a library that calls createContext or a hook at the top of its module crashes the moment a server component imports it. The fix is anticlimactic: a wrapper file with 'use client' on the first line that re-exports the library's component, which your server code imports instead. You will write a few of these wrappers in any real app — the first costs an hour of confusion, the rest a minute each.

Start on the server, move when the component needs hands

The placement rule this article has been building to fits in one sentence: start every component on the server, and move it to the client only when it needs hands — when it must respond to the user after the page has loaded. Not when it might one day, not because the file next to it did, but when it does.

Do not refactor your whole app to apply it. Pick one page — the most read-heavy one you have, ideally something with a useEffect fetch and a spinner — and rebuild it the way the refactor above does: a server component for the data and layout, client islands for the interactions. Budget a few days of feeling slow; none of the rules are hard in isolation, but your old instincts will mislead you at first, and that is normal. The bundle diff will make the argument better than I can.

Then carry the default into everything you build: every new component starts on the server, and 'use client' is something you add at the leaves, with a reason. After a few weeks the question inverts — you stop asking when to use server components and start asking what, exactly, this component needs the browser for. Most of the time the answer is nothing, and that answer is the whole point.