MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Server Components: The Future of React

Deep dive into RSC: how they work, when to use them, and their impact on the ecosystem.

ReactServer ComponentsRSCFrontend

By MinhVo

Introduction

React Server Components (RSC) represent a fundamental paradigm shift in how we build React applications. Introduced by the React team in late 2020 and gaining mainstream adoption through frameworks like Next.js 13+, RSC blur the traditional boundaries between server and client rendering. Unlike conventional React components that execute entirely in the browser, Server Components run exclusively on the server, emitting a serialized component tree that the client can efficiently render without downloading the component's JavaScript.

This architecture addresses one of the most persistent challenges in modern web development: the ever-growing JavaScript bundle size. As applications scale, the amount of JavaScript shipped to clients increases, leading to slower initial page loads, higher Time to Interactive (TTI) metrics, and degraded user experiences on lower-end devices and networks. RSC tackle this problem at its root by keeping server-only dependencies—database clients, heavy utility libraries, and data-fetching logic—entirely on the server.

The impact of RSC extends beyond performance. They fundamentally change how developers think about data fetching, component composition, and the relationship between frontend and backend code. In this comprehensive guide, we'll explore the architecture of React Server Components, understand their rendering model, examine practical implementation patterns, and evaluate their role in the future of the React ecosystem.

React Server Components Architecture

Understanding React Server Components: Core Concepts

What Are Server Components?

React Server Components are components that render exclusively on the server. When a request arrives, the server executes these components, fetches any necessary data, and produces a serialized representation of the component tree—called the RSC payload. This payload is streamed to the client, where React's reconciler merges it into the DOM without re-executing the component logic.

The critical distinction is that Server Components never ship their JavaScript to the client. A Server Component that imports a 500KB database driver or a markdown parsing library adds zero bytes to the client bundle. This is fundamentally different from Server-Side Rendering (SSR), where the component still ships to the client for hydration.

The Rendering Model

RSC introduce a dual rendering model. Applications contain both Server Components and Client Components, and understanding their interaction is essential:

  • Server Components run on the server during each request. They can directly access databases, file systems, and internal APIs. They cannot use hooks like useState or useEffect, and they cannot attach event handlers.
  • Client Components are marked with the "use client" directive. They run on both server (for SSR) and client (for interactivity). They can use hooks, event handlers, and browser APIs.
  • The boundary between them is clearly delineated. A Server Component can render a Client Component, but a Client Component can only import other Client Components or receive Server Components as children props.

RSC Payload

The RSC payload is a streaming format that describes the component tree. It contains rendered HTML for Server Components, references to Client Component bundles, and placeholders for suspense boundaries. The client-side React runtime reads this payload and progressively renders the UI.

// This is a Server Component — no "use client" directive
// It runs only on the server
import { db } from '@/lib/database';
import { PostCard } from './PostCard'; // Client Component
 
async function BlogList() {
  // Direct database access — this code never reaches the client
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
    include: { author: true },
  });
 
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}
 
export default BlogList;

Server Client Boundary

Architecture and Design Patterns

The Server-Client Boundary

The "use client" directive marks the boundary between server and client worlds. Everything imported by a Client Component becomes part of the client bundle. This creates a tree-cutting optimization opportunity:

// ❌ Inefficient: pulls expensive dependency into client bundle
'use client';
import heavyLibrary from 'heavy-library'; // Ships to client
 
function Widget({ data }) {
  return <div>{heavyLibrary.format(data)}</div>;
}
// âś… Efficient: expensive work stays on the server
import heavyLibrary from 'heavy-library'; // Stays on server
 
async function WidgetServer({ id }) {
  const data = await fetchFromDB(id);
  const formatted = heavyLibrary.format(data);
  return <WidgetClient formatted={formatted} />;
}
 
// Client Component receives only the pre-formatted result
'use client';
function WidgetClient({ formatted }) {
  return <div className="interactive">{formatted}</div>;
}

Composition Patterns

RSC encourage a composition pattern where Server Components handle data fetching and business logic, while Client Components handle interactivity:

// Server Component: layout and data
async function Dashboard() {
  const user = await getCurrentUser();
  const metrics = await getMetrics(user.id);
 
  return (
    <DashboardShell user={user}>
      <MetricsGrid metrics={metrics} />
      <InteractiveChart data={metrics.chartData} />
      <NotificationBell userId={user.id} />
    </DashboardShell>
  );
}

Streaming and Suspense Integration

RSC integrate natively with React Suspense for streaming. Wrap data-dependent sections in <Suspense> boundaries to progressively stream content:

import { Suspense } from 'react';
 
function Page() {
  return (
    <main>
      <Header /> {/* Renders immediately */}
      <Suspense fallback={<PostsSkeleton />}>
        <BlogPosts /> {/* Streams when ready */}
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <RecentComments /> {/* Streams independently */}
      </Suspense>
    </main>
  );
}

Step-by-Step Implementation

Setting Up RSC in Next.js 13+

Next.js 13+ uses the App Router, which enables RSC by default. All components in the app/ directory are Server Components unless explicitly marked otherwise:

// app/posts/page.tsx — Server Component by default
import { prisma } from '@/lib/prisma';
 
export default async function PostsPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  });
 
  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">Latest Posts</h1>
      <div className="grid gap-6">
        {posts.map(post => (
          <article key={post.id} className="border rounded-lg p-6">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-600 mt-2">{post.excerpt}</p>
            <time className="text-sm text-gray-400">
              {new Date(post.createdAt).toLocaleDateString()}
            </time>
          </article>
        ))}
      </div>
    </div>
  );
}

Creating Interactive Client Components

When you need interactivity, create a Client Component:

'use client';
 
import { useState, useTransition } from 'react';
 
interface LikeButtonProps {
  postId: string;
  initialCount: number;
}
 
export function LikeButton({ postId, initialCount }: LikeButtonProps) {
  const [likes, setLikes] = useState(initialCount);
  const [isPending, startTransition] = useTransition();
 
  const handleLike = () => {
    startTransition(async () => {
      setLikes(prev => prev + 1);
      await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
    });
  };
 
  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 rounded"
    >
      ❤️ {likes}
    </button>
  );
}

Data Fetching Patterns

Server Components enable direct data fetching without API routes:

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { cache } from 'react';
 
const getPost = cache(async (slug: string) => {
  return prisma.post.findUnique({
    where: { slug },
    include: {
      author: { select: { name: true, avatar: true } },
      tags: true,
    },
  });
});
 
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return { title: post?.title ?? 'Post Not Found' };
}
 
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  if (!post) notFound();
 
  return (
    <article className="prose mx-auto py-8">
      <h1>{post.title}</h1>
      <div className="flex gap-2 mb-4">
        {post.tags.map(tag => (
          <span key={tag.id} className="badge">{tag.name}</span>
        ))}
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Implementation Workflow

Real-World Use Cases

Use Case 1: E-Commerce Product Pages

Product pages benefit enormously from RSC. The product details, pricing, inventory status, and reviews can all be fetched and rendered on the server, while interactive elements like add-to-cart buttons, image carousels, and size selectors remain as Client Components.

async function ProductPage({ params }: { params: { id: string } }) {
  const [product, reviews] = await Promise.all([
    getProduct(params.id),
    getReviews(params.id),
  ]);
 
  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
      <ProductGallery images={product.images} /> {/* Client */}
      <div>
        <ProductInfo product={product} />           {/* Server */}
        <AddToCartButton productId={product.id} />  {/* Client */}
        <ReviewList reviews={reviews} />            {/* Server */}
      </div>
    </div>
  );
}

Use Case 2: Dashboard Applications

Dashboards with multiple data panels benefit from streaming. Each panel can be wrapped in its own Suspense boundary, allowing the most critical data to appear first:

function AnalyticsDashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<MetricCardSkeleton />}>
        <RevenueCard />
      </Suspense>
      <Suspense fallback={<MetricCardSkeleton />}>
        <UsersCard />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <RecentTransactions />
      </Suspense>
    </div>
  );
}

Use Case 3: Content-Heavy Applications

Blog platforms, documentation sites, and CMS-driven applications see the most dramatic improvements. Markdown rendering, syntax highlighting, and content processing happen entirely on the server:

import { marked } from 'marked'; // Never ships to client
import hljs from 'highlight.js'; // Never ships to client
 
async function ArticlePage({ slug }: { slug: string }) {
  const article = await getArticle(slug);
  const html = marked(article.content, {
    highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
  });
 
  return (
    <article className="prose">
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
      <ShareButtons url={`/articles/${slug}`} /> {/* Client */}
    </article>
  );
}

Best Practices for Production

  1. Default to Server Components: Only add "use client" when you need interactivity, browser APIs, or hooks. Most components don't need client-side JavaScript.

  2. Push the client boundary down: Keep Client Components as small and leaf-level as possible. Instead of making an entire page a Client Component, extract only the interactive parts.

  3. Colocate data fetching with components: Server Components can fetch their own data, eliminating the waterfall problem of useEffect-based fetching.

  4. Use cache() for request deduplication: Wrap data-fetching functions with React's cache() to avoid duplicate queries when multiple components need the same data.

  5. Leverage streaming with Suspense: Place Suspense boundaries strategically around slower data-fetching sections to optimize perceived performance.

  6. Avoid passing non-serializable props: Server Components can only pass serializable data (strings, numbers, plain objects, arrays) to Client Components. Functions, Dates, and class instances cannot cross the boundary.

  7. Use generateMetadata for SEO: Server Components can export metadata functions that Next.js uses to generate <head> tags, enabling dynamic SEO without client JavaScript.

  8. Implement proper error boundaries: Use error.tsx files in the App Router to create error boundaries that catch Server Component failures gracefully.

Common Pitfalls and Solutions

PitfallImpactSolution
Marking entire tree as "use client"All dependencies ship to client; no RSC benefitsExtract interactive leaves into small Client Components
Passing functions to Client ComponentsSerialization error at runtimeMove event handlers into the Client Component itself
Using hooks in Server ComponentsBuild error: "useState is not defined"Move the component to client or restructure to avoid state
Importing client-only libraries in Server ComponentsServer crash or unexpected behaviorKeep client libraries behind the "use client" boundary
Forgetting Suspense boundaries for async dataEntire page blocks until slowest fetch resolvesWrap independent data sections in Suspense with fallbacks
Not caching database queriesDuplicate queries during renderUse React cache() or framework-level caching (e.g., Next.js unstable_cache)

Performance Optimization

RSC deliver significant performance improvements through several mechanisms:

// Bundle analysis showing the impact
// Before RSC: 450KB JavaScript shipped to client
// After RSC: 120KB JavaScript shipped to client
 
// Heavy dependencies stay on server
import { compileMDX } from 'next-mdx-remote/rsc';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import remarkGfm from 'remark-gfm';
 
// These imports add ZERO bytes to client bundle
async function MDXContent({ source }: { source: string }) {
  const { content } = await compileMDX({
    source,
    options: { mdxOptions: { remarkPlugins: [remarkGfm] } },
    components: { pre: CodeBlock },
  });
  return <div className="prose">{content}</div>;
}

Key performance benefits:

  • Reduced bundle size: Server-only dependencies are never sent to the client
  • Faster TTI: Less JavaScript means the main thread is free sooner
  • Improved streaming: Independent sections load progressively
  • Eliminated client waterfalls: Data fetching happens on the server in a single roundtrip

Comparison with Alternatives

FeatureReact Server ComponentsServer-Side Rendering (SSR)Static Site Generation (SSG)Client-Side Rendering (CSR)
JavaScript shippedMinimal (client components only)Full bundle for hydrationFull bundle for hydrationFull bundle
Data fetchingServer-side, per requestServer-side, per requestBuild timeClient-side, after load
InteractivityImmediate for client componentsAfter hydrationAfter hydrationAfter JavaScript loads
Dynamic contentFull supportFull supportLimitedFull support
SEOExcellent (server-rendered HTML)ExcellentExcellentPoor without SSR
Server loadModerateHighLow (CDN)Low

Advanced Patterns

Passing Server Components as Props

A powerful pattern allows Server Components to be passed as children to Client Components:

// Client Component with server content inside
'use client';
function Accordion({ children, title }: { children: React.ReactNode; title: string }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>{title}</button>
      {isOpen && children}
    </div>
  );
}
 
// Server Component passes rendered content as children
async function FAQSection() {
  const faqs = await getFAQs();
  return (
    <div>
      {faqs.map(faq => (
        <Accordion key={faq.id} title={faq.question}>
          <p>{faq.answer}</p> {/* Rendered on server, interactive on client */}
        </Accordion>
      ))}
    </div>
  );
}

Parallel Data Fetching

async function DashboardPage() {
  // Fetch in parallel — no waterfall
  const [user, analytics, notifications] = await Promise.all([
    getUser(),
    getAnalytics(),
    getNotifications(),
  ]);
 
  return (
    <DashboardLayout user={user}>
      <AnalyticsPanel data={analytics} />
      <NotificationList items={notifications} />
    </DashboardLayout>
  );
}

Route-Level Caching with revalidate

// Revalidate every 60 seconds
export const revalidate = 60;
 
async function PricingPage() {
  const plans = await fetch('https://api.example.com/plans', {
    next: { revalidate: 60 },
  }).then(r => r.json());
 
  return <PricingTable plans={plans} />;
}

Testing Strategies

Testing RSC requires distinguishing between server and client component tests:

// Testing a Server Component
import { renderToString } from 'react-dom/server';
import { BlogList } from './BlogList';
 
// Mock the database
jest.mock('@/lib/database', () => ({
  db: { post: { findMany: jest.fn().mockResolvedValue(mockPosts) } },
}));
 
test('renders blog posts from database', async () => {
  const html = renderToString(await BlogList());
  expect(html).toContain('Test Post Title');
});
 
// Testing a Client Component
import { render, screen, fireEvent } from '@testing-library/react';
import { LikeButton } from './LikeButton';
 
test('increments like count on click', () => {
  render(<LikeButton postId="1" initialCount={5} />);
  fireEvent.click(screen.getByRole('button'));
  expect(screen.getByRole('button')).toHaveTextContent('6');
});

Future Outlook

React Server Components are the foundation of React's future direction. The React team has made it clear that RSC are not an experimental feature—they represent the new default architecture for React applications. Key developments to watch:

  • React Compiler: The auto-memoizing compiler will reduce the need for manual useMemo and useCallback, further simplifying the server-client boundary management.
  • Server Actions: The evolution of server-side mutations that integrate seamlessly with RSC, replacing API routes for many use cases.
  • Framework convergence: While Next.js leads RSC adoption, Remix, Waku, and other frameworks are implementing their own RSC support.
  • Partial prerendering: Combining static shell rendering with dynamic server-rendered content in a single request.

Conclusion

React Server Components represent a paradigm shift that makes React applications faster, leaner, and more architecturally sound. By moving data fetching and heavy computation to the server, RSC dramatically reduce client-side JavaScript while enabling streaming, progressive rendering, and better developer ergonomics.

Key takeaways:

  1. RSC run only on the server and ship zero JavaScript to the client
  2. Use "use client" only for components that need interactivity
  3. Compose Server and Client Components to optimize both performance and UX
  4. Leverage Suspense boundaries for streaming and progressive loading
  5. Direct data fetching in Server Components eliminates API route overhead

The future of React is server-first. Start adopting RSC in new projects today, and gradually migrate existing components by pushing client boundaries down to the interactive leaves of your component tree.