Skip to content
All posts
Tutorial5 min read

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

DebuggerMe TeamDebuggerMe TeamApril 24, 2026
Code on a laptop screen with React components
On this page

React Server Components (RSC) are the biggest paradigm shift in React since hooks. But after two years in production, there's still confusion about what they actually are — and more importantly, when not to use 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:

code
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: remark, gray-matter, and fs — none of these 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.

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

In production, RSC can dramatically reduce bundle sizes. A typical content site that adopted RSC saw:

MetricBefore RSCAfter RSC
JS bundle (gzip)178KB52KB
Time to Interactive3.8s1.2s
Largest Contentful Paint2.9s1.0s

The reduction comes from libraries like date-fns, marked, and schema validators no longer shipping to the browser.

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
DebuggerMe Team

Written by

DebuggerMe Team

The DebuggerMe team builds developer tools, writes technical content, and helps teams ship better software.

Share this post

Back to all posts

Related Articles

All articles →