Skip to content
All posts
Tutorial8 min read

React Server Components in Depth: What They Are and When to Use Them

J
Jamith Nimantha
April 24, 2026
Code on a laptop screen with React components
Photo by Unsplash
On this page

A Server Component runs once, on the server, and never ships to the browser. Its code, its imports, and its dependencies are all absent from the client bundle. What arrives is the rendered output, not the component.

That is the whole idea. 'use client' marks the boundary where that stops being true. Everything below is the consequences: what you gain, what you can no longer do, and the two mistakes that make people give up on them.

This guide cuts through the noise.

What Problem Do They Solve?

Before RSC, every React component ran in the browser. Even if you fetched data on the server (via getServerSideProps or loaders), the component that rendered that data still shipped its JavaScript to the client.

This created a hidden cost: bundle bloat. Libraries imported in components (markdown parsers, date formatters, data validators) all went to the browser, even when they were only needed during rendering.

RSC solves this by running components on the server and sending only the rendered output (HTML + a serialized description) to the client.

[!NOTE] Server Components never run in the browser. They produce output once, at request time or build time, and the result is streamed to the client. Their JavaScript is never sent to the browser.

The Mental Model

Think of your component tree as split into two worlds:

text
App (Server)
├── Layout (Server)
├── Header (Server)
├── Page (Server)
│   ├── ArticleContent (Server) ← reads MDX from disk
│   ├── TableOfContents (Client) ← uses useEffect + IntersectionObserver
│   └── ShareButtons (Client) ← uses useState, navigator.clipboard
└── Footer (Server)

The rule: push interactivity to the leaves. Most of your tree should be Server Components. Only the parts that need browser APIs, state, or event handlers need to be Client Components.

Async Server Components

The killer feature is async/await directly in your component:

tsx
// This component never ships to the browser
export default async function ArticlePage({ params }: { params: { slug: string } }) {
  // Direct filesystem access - no API layer needed
  const article = await fs.readFile(`content/${params.slug}.mdx`, 'utf-8');
  const { data, content } = matter(article);

  // This import is never in the client bundle
  const { remark } = await import('remark');

  return (
    <article>
      <h1>{data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </article>
  );
}

Notice that remark, gray-matter, and fs never end up in your client bundle.

Crossing the Boundary

Passing data from Server to Client Components is straightforward but has one critical rule: props must be serializable.

tsx
// Server Component
export default async function ProductPage() {
  const product = await db.products.find({ id: 1 });

  return (
    <div>
      <ProductImages images={product.images} /> {/* Server: no interactivity */}
      <AddToCart productId={product.id} price={product.price} /> {/* Client: needs state */}
    </div>
  );
}

// Client Component
"use client";
export function AddToCart({ productId, price }: { productId: number; price: number }) {
  const [quantity, setQuantity] = useState(1);
  // ...
}

[!TIP] You cannot pass a class instance, a function, or a Date object as a prop from Server to Client. Serialize to primitives (strings, numbers, arrays, plain objects) before crossing the boundary.

What you can no longer do

Server Components have no state, no effects, and no browser APIs, because none of those concepts exist during a single server render. The compiler catches most of it, but the mental model matters more than the error messages:

Not availableWhy
useState, useReducerthere is no re-render to schedule
useEffectthere is no mount, and no client to run on
Event handlers (onClick)a function cannot be serialised into HTML
window, document, localStorageno browser at render time
Context via useContextproviders are a client-tree concept

The compensation is a capability client components never had: a Server Component can be async and await directly in the body, so data fetching happens during render rather than after it. No loading state, no effect, no waterfall between mount and fetch.

The serialisation boundary is a real constraint

Props crossing from a Server Component into a Client Component must survive serialisation. Strings, numbers, plain objects, arrays, Date, Map, Set and Promises all cross. Functions and class instances do not:

tsx
// Server Component
<ClientChart
  data={rows}                    // fine
  formatLabel={(r) => r.name}    // Error: functions cannot be passed
/>

The error is explicit about it, and the fix is to move the function into the client component rather than to pass it. This is also why an ORM model instance often fails to cross while the plain object it wraps succeeds, which is worth knowing before you spend an hour on it.

Common Mistake: The "Everything Server" Trap

New RSC adopters often overcorrect and try to make everything a Server Component. This breaks when you need:

  • useState / useReducer / useContext
  • useEffect / useLayoutEffect
  • Browser APIs (window, document, navigator)
  • Event handlers (onClick, onChange)
  • Third-party client libraries (charts, drag-and-drop, animations)

The fix is simple: extract the interactive piece into its own "use client" component.

Data Fetching Patterns

Pattern 1: Fetch at the page level

tsx
// app/dashboard/page.tsx (Server)
export default async function DashboardPage() {
  const [user, stats, recentActivity] = await Promise.all([
    fetchUser(),
    fetchStats(),
    fetchActivity(),
  ]);

  return <Dashboard user={user} stats={stats} activity={recentActivity} />;
}

Pattern 2: Fetch inside components

tsx
// Each component fetches what it needs - Next.js deduplicates identical requests
async function UserAvatar({ userId }: { userId: string }) {
  const user = await fetchUser(userId); // Cached and deduped by React
  return <img src={user.avatar} alt={user.name} />;
}

Pattern 3: Streaming with Suspense

tsx
export default function Page() {
  return (
    <>
      <Suspense fallback={<HeaderSkeleton />}>
        <Header /> {/* Slow - resolves in 50ms */}
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <Feed /> {/* Slow - resolves in 800ms */}
      </Suspense>
    </>
  );
}

With Suspense, the fast parts stream first. Users see a skeleton for slow parts instead of a blank page.

Performance Impact

The gain is real but it is entirely a function of what you were shipping before. Moving a component to the server removes its dependencies from the client bundle, so the saving is exactly the weight of the libraries that component imported.

That means the wins concentrate in a predictable place: formatting and parsing libraries such as date-fns, marked, and schema validators, which are large, run once, and produce output the browser only needs to display. A component whose only dependency is React itself saves you nothing by moving.

Measure your own before and after with @next/bundle-analyzer rather than trusting a number from someone else's app. The figures quoted in RSC posts vary by an order of magnitude because the codebases do.

'use client' marks a boundary, not a file

The most common misreading. 'use client' does not mean "this one component is a client component", it means "everything imported from here down is client code". Put it at the top of your layout and you have opted the entire tree in, which is how apps end up shipping the bundle they were trying to avoid.

Two consequences follow, and the second surprises people:

Push the directive to the leaves. A page that is server-rendered except for one interactive dropdown should mark the dropdown, not the page.

A Server Component can still be rendered inside a Client Component, via children. The boundary is about the import graph, not the render tree. This is the escape hatch that makes the pattern usable:

tsx
// ClientShell.tsx
'use client';
export function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return <div onClick={() => setOpen(!open)}>{children}</div>;
}
tsx
// page.tsx  (still a Server Component)
<ClientShell>
  <ExpensiveServerComponent />   {/* stays on the server */}
</ClientShell>

ExpensiveServerComponent never enters the client bundle, because ClientShell receives it as already-rendered output rather than importing it. Any time you think "I need this interactive wrapper, so the content has to be client too", this is the pattern that says otherwise.

Summary

Server Components are not a silver bullet; they're a new primitive that handles a specific job: rendering data-heavy UI on the server without sending that logic to the browser.

Use them by default. Add "use client" only when you need it. Keep your interactivity at the leaves. That's the entire mental model.

Tools in this post

Related Tool

JSON Parser & Formatter

Validate, format, and minify JSON data with error highlighting.

Try it free
J

Written by

Jamith Nimantha

Software developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.

Share this post

Back to all posts

Related Articles

All articles →