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: Patterns and Anti-Patterns

Master RSC: data fetching strategies, client component boundaries, serialization rules, and performance patterns for production React apps.

ReactRSCServer ComponentsFrontend

By MinhVo

Introduction

React Server Components (RSC) fundamentally change how React applications are structured. Instead of shipping all component logic to the browser, RSC lets you run components on the server — accessing databases, file systems, and secrets directly — while sending only the rendered output to the client. This reduces bundle size, eliminates data waterfalls, and simplifies architecture.

But RSC also introduces new rules. The boundary between server and client components creates constraints that, if violated, lead to runtime errors, security vulnerabilities, or performance regressions. This guide covers the patterns that work, the anti-patterns that don't, and the mental model you need to build production-grade RSC applications.

Server architecture

The RSC Mental Model

Two Types of Components

RSC splits React components into two categories with different capabilities and constraints:

Server Components (default in Next.js App Router):

  • Run on the server during request handling
  • Can async/await — access databases, APIs, file systems
  • Cannot use hooks (useState, useEffect, useRef)
  • Cannot use browser APIs
  • Cannot use event handlers (onClick, onChange)
  • Zero JavaScript sent to client

Client Components (opt-in with 'use client'):

  • Run on both server (SSR) and client (hydration)
  • Can use all React hooks
  • Can use browser APIs and event handlers
  • JavaScript is sent to client for hydration
  • Cannot access server-only resources directly
// Server Component — no JavaScript shipped
async function ProductList() {
  const products = await db.products.findMany(); // Direct DB access
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}
 
// Client Component — JavaScript shipped to client
'use client';
import { useState } from 'react';
 
function ProductFilter() {
  const [category, setCategory] = useState('all');
  return (
    <select value={category} onChange={e => setCategory(e.target.value)}>
      <option value="all">All</option>
      <option value="electronics">Electronics</option>
    </select>
  );
}

The Boundary Rule

The 'use client' directive marks the boundary. Everything imported by a client component becomes client-side too. This means:

// If this is a client component...
'use client';
import { HeavyChart } from './HeavyChart'; // ← Also client-side now
import { formatCurrency } from './utils';   // ← Also client-side now
 
// Server components can import client components
// But client components CANNOT import server components

This unidirectional import rule is the most important constraint in RSC.

Pattern 1: Composition Over Conversion

Instead of converting a server component to a client component, compose them:

// BAD: Converting to client just to wrap children
'use client';
function DashboardLayout({ children }) {
  return (
    <div>
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}
 
// GOOD: Keep layout as server component, extract interactive parts
// app/dashboard/layout.tsx (server component)
function DashboardLayout({ children }) {
  return (
    <div>
      <Sidebar />          {/* client component */}
      <main>{children}</main>
    </div>
  );
}
 
// app/dashboard/sidebar.tsx (client component)
'use client';
function Sidebar() {
  const [collapsed, setCollapsed] = useState(false);
  return (
    <aside className={collapsed ? 'collapsed' : ''}>
      <button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
      {/* nav items */}
    </aside>
  );
}

Pattern 2: Server Component Data Fetching

Parallel Fetching

// Both queries run in parallel — no waterfall
async function DashboardPage() {
  const [user, posts, analytics] = await Promise.all([
    getUser(),
    getPosts(),
    getAnalytics(),
  ]);
 
  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  );
}

Streaming with Suspense

// Components stream independently
async function DashboardPage() {
  return (
    <div>
      <Header />  {/* Renders immediately */}
 
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />  {/* Streams when data ready */}
      </Suspense>
 
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />  {/* Streams independently */}
      </Suspense>
    </div>
  );
}

Cached Fetching

import { unstable_cache } from 'next/cache';
 
const getProducts = unstable_cache(
  async () => db.products.findMany(),
  ['products'],
  { revalidate: 3600, tags: ['products'] }
);
 
async function ProductPage() {
  const products = await getProducts(); // Cached for 1 hour
  // ...
}

Data flow patterns

Pattern 3: Passing Server Data to Client Components

Client components can't fetch server resources directly, but server components can pass data to them as props:

// Server component fetches data
async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.products.findUnique({
    where: { id: params.id },
  });
 
  const reviews = await db.reviews.findMany({
    where: { productId: params.id },
  });
 
  // Pass server data to client component as props
  return (
    <div>
      <ProductDetails product={product} />
      <ReviewSection initialReviews={reviews} productId={params.id} />
    </div>
  );
}
 
// Client component uses data for interactivity
'use client';
function ReviewSection({ initialReviews, productId }) {
  const [reviews, setReviews] = useState(initialReviews);
  const [sortBy, setSortBy] = useState('newest');
 
  const sorted = useMemo(() => {
    return [...reviews].sort((a, b) =>
      sortBy === 'newest'
        ? new Date(b.createdAt) - new Date(a.createdAt)
        : b.rating - a.rating
    );
  }, [reviews, sortBy]);
 
  return (
    <div>
      <select value={sortBy} onChange={e => setSortBy(e.target.value)}>
        <option value="newest">Newest</option>
        <option value="rating">Highest Rated</option>
      </select>
      {sorted.map(review => <ReviewCard key={review.id} review={review} />)}
    </div>
  );
}

Pattern 4: Server Actions for Mutations

// actions.ts
'use server';
 
export async function addToCart(productId: string, quantity: number) {
  const session = await getSession();
  if (!session) throw new Error('Not authenticated');
 
  await db.cartItems.upsert({
    where: {
      userId_productId: { userId: session.userId, productId },
    },
    update: { quantity: { increment: quantity } },
    create: { userId: session.userId, productId, quantity },
  });
 
  revalidatePath('/cart');
  return { success: true };
}
// Client component calls server action
'use client';
import { addToCart } from './actions';
 
function AddToCartButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition();
 
  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => addToCart(productId, 1))}
    >
      {isPending ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Anti-Pattern 1: 'use client' at the Top Level

Placing 'use client' in your root layout or top-level pages defeats RSC's benefits:

// BAD: Everything is client-side
// app/layout.tsx
'use client';
export default function RootLayout({ children }) {
  return <html><body>{children}</body></html>;
}
 
// GOOD: Keep root as server component
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Navigation /> {/* client component, only this */}
        {children}     {/* server components by default */}
      </body>
    </html>
  );
}

Anti-Pattern 2: Passing Non-Serializable Props

Server components can only pass serializable data to client components:

// BROKEN: Functions, Dates, and class instances are not serializable
async function Page() {
  const handleClick = () => console.log('clicked');
  return <Button onClick={handleClick} />; // Error!
}
 
// BROKEN: Date objects
async function Page() {
  const date = new Date();
  return <DatePicker value={date} />; // May cause issues
}
 
// FIX: Pass primitive values
async function Page() {
  return <DatePicker value={new Date().toISOString()} />; // String is serializable
}

Anti-Pattern 3: Importing Server Modules in Client Components

// BROKEN: Database module in client component
'use client';
import { db } from '@/lib/database'; // This exposes DB to the client!
 
// FIX: Use server actions for data access
'use server';
export async function getUsers() {
  return db.users.findMany();
}

Anti-Pattern 4: No Suspense Boundaries for Slow Queries

// BAD: Entire page waits for slowest query
async function DashboardPage() {
  const fastData = await getFastData();     // 50ms
  const slowData = await getSlowData();     // 2000ms
  return <Dashboard fast={fastData} slow={slowData} />;
}
 
// GOOD: Stream content progressively
async function DashboardPage() {
  return (
    <div>
      <FastSection />    {/* Renders in 50ms */}
      <Suspense fallback={<SlowSkeleton />}>
        <SlowSection />  {/* Streams after 2000ms */}
      </Suspense>
    </div>
  );
}

Performance monitoring

Pattern 5: Shared Layout Data

// app/layout.tsx — Server component
async function RootLayout({ children }) {
  const session = await getSession();
 
  return (
    <html>
      <body>
        <Header user={session?.user} />
        {children}
      </body>
    </html>
  );
}
 
// Client component receives data from server layout
'use client';
function Header({ user }) {
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <header>
      <button onClick={() => setMenuOpen(!menuOpen)}>Menu</button>
      {user ? <span>Welcome, {user.name}</span> : <a href="/login">Login</a>}
    </header>
  );
}

Pattern 6: Error Handling

// Server component with error handling
async function ProductPage({ params }) {
  try {
    const product = await getProduct(params.id);
    return <ProductDetails product={product} />;
  } catch (error) {
    if (error.code === 'NOT_FOUND') {
      notFound();
    }
    throw error; // Let error boundary handle it
  }
}
 
// app/products/[id]/error.tsx
'use client';
export default function ProductError({ error, reset }) {
  return (
    <div>
      <h2>Failed to load product</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Serialization Rules

What can be passed from server to client components:

TypeSerializable?Notes
String
Number
Boolean
null/undefined
Plain objectsNo class instances
Arrays
DateUse .toISOString()
Map/SetConvert to array
FunctionsUse server actions
React elementsChildren are serializable
PromisesFor streaming

Best Practices

  1. Default to server components — Only add 'use client' when needed
  2. Push client boundaries down — Keep them as leaf nodes
  3. Compose, don't convert — Wrap server components in client shells
  4. Fetch in server components — Eliminate client-side waterfalls
  5. Use Suspense for streaming — Progressive rendering improves UX
  6. Pass serializable data only — Strings, numbers, plain objects
  7. Use server actions for mutations — Built-in security and progressive enhancement
  8. Cache with tags — Fine-grained invalidation control

Common Pitfalls

PitfallImpactSolution
'use client' at rootNo RSC benefitsRestrict to interactive components
Non-serializable propsRuntime errorsUse primitives and plain objects
Server imports in clientSecurity leakUse server actions
No Suspense boundariesSlow TTFBWrap slow queries
Client-side waterfallsSlow page loadsFetch in server components
Missing error handlingWhite screensAdd error.tsx files

Performance Monitoring for RSC

Monitor Server Component performance by tracking server rendering time, serialized payload size, and client hydration time. Use OpenTelemetry or similar tracing libraries to instrument your Server Components and measure the time spent in data fetching, rendering, and serialization. Set up alerts for rendering time regressions that may indicate slow database queries or external API calls. Compare Server Component rendering times against client-side rendering times to verify that the server-side approach provides the expected performance benefits. Use these metrics to identify optimization opportunities and track the impact of changes over time.

Migration from Client Components

When migrating existing Client Components to Server Components, follow a systematic approach. First, identify components that do not use client-side interactivity like useState, useEffect, or event handlers. These are candidates for Server Components. Second, extract the interactive parts into separate Client Components that receive data as props from the parent Server Component. Third, verify that the migrated component correctly fetches data on the server and passes it to the client components. Fourth, test the migration by comparing the rendered output and behavior before and after. This incremental approach minimizes risk and allows you to validate each migration step independently.

Understanding the boundary between server and client components is essential for building performant React applications that leverage the best of both worlds.

Understanding the boundary between server and client components is essential for building performant React applications that leverage the best of both worlds while avoiding common pitfalls that lead to unnecessary client-side JavaScript.

Testing Server Components

Testing Server Components requires a different approach than testing Client Components. Server Components run on the server, so they cannot be tested with traditional browser-based testing tools like Jest with jsdom. Instead, use integration tests that verify the rendered HTML output.

// server-component.test.tsx
import { renderToString } from 'react-dom/server';
import { ProductPage } from './ProductPage';
 
// Mock the database layer
jest.mock('./db', () => ({
  getProduct: jest.fn().mockResolvedValue({
    id: '1',
    name: 'Widget',
    price: 29.99,
    description: 'A useful widget',
  }),
  getReviews: jest.fn().mockResolvedValue([
    { id: '1', rating: 5, text: 'Great product!' },
  ]),
}));
 
test('ProductPage renders product data from server', async () => {
  const html = await renderToString(<ProductPage params={{ id: '1' }} />);
 
  expect(html).toContain('Widget');
  expect(html).toContain('$29.99');
  expect(html).toContain('Great product!');
});
 
test('ProductPage handles missing product gracefully', async () => {
  const { getProduct } = require('./db');
  getProduct.mockResolvedValue(null);
 
  const html = await renderToString(<ProductPage params={{ id: '999' }} />);
  expect(html).toContain('Product not found');
});

For end-to-end testing, use Playwright or Cypress to verify the full server-client interaction chain. These tools render the page in a real browser, allowing you to test Server Component streaming, Suspense boundaries, and Client Component hydration together.

Debugging Server Components

Debugging Server Components requires visibility into both server-side rendering and client-side hydration. Use React DevTools to inspect the component tree and identify which components are server-rendered versus client-rendered. Server Components display a server icon in the component tree, while Client Components display a browser icon.

For server-side debugging, add logging directly in your Server Components since they execute on the server:

// Debug helper for Server Components
async function DebugProductList() {
  console.time('ProductList:db-query');
  const products = await db.product.findMany({ take: 20 });
  console.timeEnd('ProductList:db-query');
 
  console.log(`Rendering ${products.length} products`);
  console.log('Serialized size:', JSON.stringify(products).length, 'bytes');
 
  return <ProductList products={products} />;
}

Common debugging scenarios include identifying which Server Component is causing slow streaming (check the network tab for sequential data chunks), verifying that Client Component boundaries are in the correct place (check the JavaScript bundle size), and ensuring that serialized data crossing the server-client boundary is minimal and correctly typed.

Performance Benchmarking

Benchmark your Server Component architecture by measuring three key metrics: Time to First Byte (TTFB), which measures how quickly the server starts sending HTML; Time to First Contentful Paint (FCP), which measures when the user first sees content; and Total Blocking Time (TBT), which measures how long the main thread is blocked during hydration.

// Performance measurement middleware
app.get('/products/:id', async (req, res) => {
  const start = performance.now();
 
  // Render the Server Component tree
  const stream = renderToReadableStream(
    <ProductPage params={{ id: req.params.id }} />,
    { bootstrapScripts: ['/client.js'] }
  );
 
  // Track TTFB
  const ttfb = performance.now() - start;
  res.setHeader('Server-Timing', `ttfb;dur=${ttfb}`);
 
  // Pipe the stream to the response
  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' },
  });
});
``>
 
Compare these metrics against your Client Component baseline to quantify the improvement. Most applications see a 30-50% reduction in JavaScript bundle size and a 20-40% improvement in TTFB when migrating appropriate components to Server Components.
 
## Server Components in Non-React Frameworks
 
The Server Components model has influenced other frameworks beyond React. Next.js App Router is the primary production implementation, but the concepts are spreading. Remix has adopted server-side rendering with streaming that borrows ideas from RSC without fully implementing the component protocol. Astro's island architecture shares the philosophy of shipping zero JavaScript by default and hydrating only interactive regions.
 
Vue and Svelte have explored similar concepts through their own mechanisms. Vue's server-side rendering with `@vueuse/head` and SvelteKit's load functions provide server-side data fetching patterns that achieve some of the same benefits as RSC. However, none of these frameworks implement the full RSC protocol of serializable component trees that stream from server to client, which is the key innovation that enables the patterns described in this guide.
 
Understanding RSC concepts helps you evaluate these alternatives and choose the right framework for your project. If you need the full power of server-rendered component trees with client interactivity, Next.js App Router with RSC is the most mature option. If you need simpler server-side rendering with client hydration, SvelteKit or Nuxt may be more appropriate. The key question is whether your application benefits from the fine-grained server/client boundary that RSC provides. As the ecosystem matures, expect these server-first patterns to become the default architecture for data-heavy web applications across all major frameworks.
 
## Conclusion
 
React Server Components require a mental model shift from traditional React development. The key insight is that server and client components serve complementary purposes: server components handle data and rendering, client components handle interactivity. By respecting the boundary rules and following the patterns in this guide, you can build applications that are faster, smaller, and more secure.
 
Key takeaways:
 
1. **Server components are the default** — They handle data access and rendering
2. **Client components handle interactivity** — Use `'use client'` only when needed
3. **Composition over conversion** — Wrap, don't convert
4. **Fetch in server components** — No waterfalls, direct DB access
5. **Stream with Suspense** — Progressive rendering for better UX
6. **Serialize carefully** — Only plain data crosses the boundary
7. **Server actions for mutations** — Secure, progressive enhancement built-in