Skip to content
Back to blog

React Server Components, explained without the hype

React Server Components (RSC) are the biggest change to how React apps are built since hooks, and also the most confusing. The confusion comes from the names. “Server component” sounds like “server-side rendering”, and it is not the same thing.

Here is the mental model I use in Next.js App Router projects, where every component is a server component unless you say otherwise.

What a Server Component is

A Server Component renders on the server, and only on the server. Its code never ships to the browser. The browser receives the rendered result, not the function that produced it.

That has three consequences. You can read a database or a secret key directly inside the component. Heavy libraries used only for rendering, such as a Markdown parser, add nothing to the client bundle. And the component cannot use state, effects or browser APIs, because there is no browser where it runs.

app/posts/page.tsx
import { db } from "@/lib/db";

// No "use client", so this is a Server Component.
export default async function PostsPage() {
  const posts = await db.post.findMany({ orderBy: { date: "desc" } }); 

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

The component is async and awaits data directly. There is no useEffect, no loading state to wire by hand, and no API route in between.

How it differs from SSR

Server-side rendering produces HTML on the server, then sends all the component code to the browser so React can hydrate that HTML and make it interactive. Every component still ships and runs again on the client.

Server Components are not hydrated at all. They have no client copy, so they cost zero JavaScript. Client Components can still be server-rendered to HTML on the first load, and they hydrate as usual.

The “use client” boundary

"use client" at the top of a file marks the boundary. That file and everything it imports become part of the client bundle. It does not mean “render only in the browser”, since Client Components still pre-render on the server.

Reach for it when you need state, effects, event handlers or a browser API.

LikeButton.tsx
"use client"; 

import { useState } from "react";

export default function LikeButton({ initial }: { initial: number }) {
  const [likes, setLikes] = useState(initial);

  return <button onClick={() => setLikes(likes + 1)}>Like ({likes})</button>;
}

The rule that saves bundle size is to push the boundary as far down the tree as you can. Make the small interactive leaf a Client Component, not the whole page.

app/posts/[id]/page.tsx
import LikeButton from "@/ui/LikeButton";
import { getPost } from "@/lib/posts";

export default async function Post({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton initial={post.likes} /> {/* only this ships to the browser */} // [!code highlight]
    </article>
  );
}

Passing data across the boundary

A Server Component can render a Client Component and pass it props. Those props must be serializable: strings, numbers, plain objects, arrays, dates. Functions are the usual surprise. You cannot pass an ordinary function from server to client.

The other direction has one pattern worth knowing. A Client Component cannot import a Server Component, but it can receive one as children. The server part is rendered ahead of time and slotted in.

Sidebar.tsx
"use client";

import { useState, type ReactNode } from "react";

export default function Sidebar({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(true);

  return (
    <aside>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children} {/* a Server Component passed in from the page */} // [!code highlight]
    </aside>
  );
}

This is the same trick as lifting content up with children from the memoization post, used here to keep server code out of the client bundle.

Fetching data without waterfalls

Because Server Components run next to your data, fetching gets short and fast. The trap is writing it sequentially when the requests are independent.

Waterfall
const user = await getUser(id);
const posts = await getPosts(id); // waits for user, though it doesn't need it
Parallel
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]); 

Better still, split slow parts into their own components and stream them with Suspense, so the fast parts show up first.

app/dashboard/page.tsx
import { Suspense } from "react";

export default function Dashboard() {
  return (
    <>
      <Header />
      <Suspense fallback={<p>Loading stats...</p>}>
        <Stats /> {/* async Server Component */}
      </Suspense>
    </>
  );
}
What reaches the browser
  1. Requestbrowser asks for the page
  2. Server Components rundata, secrets, heavy libs stay here
  3. Output streams downHTML plus a serialized tree
  4. Client Components hydrateonly these ship JavaScript

Server Components send their output. Client Components send their code too.

Common mistakes

  • Marking the page "use client" out of habit. It pulls the whole subtree into the bundle and throws away the benefit. Start on the server, and add the directive only to the leaf that needs it.
  • Using hooks in a Server Component. useState and useEffect fail there. The error tells you to add "use client", but first ask whether that part should be a small child component instead.
  • Passing a function as a prop across the boundary. Use a Server Action, or move the logic into the Client Component.
  • Leaking secrets into a Client Component. Anything imported into a client file ships to the browser. Keep keys and database code in server files.

When Server Components are not the answer

Highly interactive screens, such as a drawing canvas or a spreadsheet, are mostly client code anyway. Pages that depend on real-time local state gain little. RSC shines for content, lists, dashboards and anything that reads data and shows it.

They are also tied to a framework. Next.js is the mature option today, so this is not something you add to a plain Vite app in an afternoon.

Interview answer in 30 seconds

“Server Components render only on the server and ship no JavaScript, so they can read databases and secrets and keep heavy libraries out of the bundle. They cannot use state or effects. use client marks a boundary after which components are bundled for the browser. It’s different from SSR, which produces HTML but still hydrates everything. I keep the boundary low in the tree, pass serializable props across it, and stream slow parts with Suspense.”

Key takeaways

  • Server Components run only on the server and add no client JavaScript.
  • SSR and Server Components are different things, and they work together.
  • "use client" marks the start of the client bundle. Push it down to small leaves.
  • Props crossing to a Client Component must be serializable.
  • Pass Server Components into Client Components as children.
  • Fetch in parallel and stream slow parts with Suspense.