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

Next.js Server Components: Deep Dive

Master RSC: serialization, client boundaries, and the request lifecycle.

Next.jsRSCServer ComponentsFrontend

By MinhVo

Introduction

React Server Components (RSC) represent the most significant architectural shift in React since hooks. They fundamentally change where components execute—moving rendering from the browser to the server—while maintaining the composability that makes React powerful. In Next.js, Server Components are the default, and understanding their internals is essential for building performant applications.

The RSC model isn't just server-side rendering (SSR) with a new name. SSR generates HTML strings on the server. RSC generates a serializable component tree that the client can merge with client-side state. This distinction enables powerful patterns: Server Components can directly access databases, read filesystem contents, and use server-only dependencies without sending any of that code to the browser.

However, the mental model for RSC is different from traditional React. You can't use hooks in Server Components. You can't use browser APIs. Props must be serializable. The boundary between server and client code creates constraints that, once understood, lead to dramatically better application architecture. This guide explores those internals deeply.

Server Architecture

Understanding Server Components: Core Concepts

The RSC Protocol

When Next.js renders a Server Component tree, it produces an RSC payload—a serialized representation of the component tree that includes rendered output, placeholders for async components, and references to Client Components. This payload is streamed to the client, where React's runtime merges it with the existing DOM.

The payload format looks conceptually like this:

0: ["$", "div", null, {children: ["$@1", "$@2"]}]
1: ["$", "h1", null, {children: "Hello"}]
2: ["$", "button", null, {onClick: "$function:handleClick", children: "Click me"}]

Each line is a node in the component tree. Server Components render to this serializable format. Client Components are referenced by ID, and their code is loaded separately. The key insight: Server Components never send their code to the client—only their rendered output crosses the network boundary.

Serialization Boundaries

The most critical concept in RSC is the serialization boundary between Server and Client Components. When a Server Component passes props to a Client Component, those props must be serializable—meaning they can be converted to JSON-like structures.

// This works—serializable props
<ClientComponent
  title="Hello"           // string ✓
  count={42}              // number ✓
  isActive={true}         // boolean ✓
  items={['a', 'b']}      // array of primitives ✓
  user={{ name: 'Alice' }} // plain object ✓
  createdAt={new Date()}  // Date ✓ (special handling)
/>
 
// This does NOT work
<ClientComponent
  onClick={() => {}}       // ✗ functions aren't serializable
  header={<h1>Title</h1>} // ✓ JSX is serializable
  config={new Map()}       // ✗ Map isn't serializable
  regex={/pattern/}        // ✗ RegExp isn't serializable
/>

JSX is serializable because React elements are plain objects ({ type, props, key }). This means Server Components can pass JSX as children to Client Components—the JSX renders on the server, serializes, and hydrates on the client.

The Request Lifecycle

Understanding the order of operations helps you reason about data fetching, caching, and rendering:

  1. Request arrives: Next.js matches the URL to a page component
  2. Server Components render: The component tree is rendered on the server, starting from the root layout. Async components (await db.query()) resolve during this phase.
  3. RSC payload generated: The rendered tree is serialized into the RSC payload format
  4. HTML streamed: The initial HTML is generated from the RSC payload and streamed to the client
  5. Client hydration: React on the client processes the RSC payload, mounts Client Components, and attaches event listeners
  6. Streaming updates: Async Server Components that haven't resolved continue streaming their output as <script> tags that update the DOM

This lifecycle means the user sees content as soon as the first Server Component resolves—even if deeper components are still loading.

Data Flow Architecture

Architecture and Design Patterns

The Server-First Pattern

Default to Server Components. Only use Client Components when you need interactivity, browser APIs, or hooks:

// app/dashboard/page.tsx — Server Component (default)
import { DashboardHeader } from '@/components/DashboardHeader'   // Client (needs click handlers)
import { MetricsGrid } from '@/components/MetricsGrid'          // Client (needs chart library)
import { RecentOrders } from '@/components/RecentOrders'         // Server (just data display)
import { db } from '@/lib/db'
 
export default async function DashboardPage() {
  // Direct database access—no API layer needed
  const [metrics, orders] = await Promise.all([
    db.metrics.getLatest(),
    db.orders.findMany({ orderBy: { createdAt: 'desc' }, take: 10 }),
  ])
 
  return (
    <div>
      <DashboardHeader />
      <MetricsGrid data={metrics} />
      <RecentOrders orders={orders} />
    </div>
  )
}

The page itself is a Server Component that fetches data and passes it down. Only DashboardHeader (needs click handlers) and MetricsGrid (needs a chart library with canvas) are Client Components. RecentOrders remains a Server Component.

Composing Server and Client Components

The composition pattern matters more than the component count. The goal is to push Client Component boundaries as deep as possible:

// Bad: Entire list is a Client Component
'use client'
export function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <FollowButton userId={user.id} />
        </li>
      ))}
    </ul>
  )
}
 
// Good: Only the interactive part is a Client Component
// UserList is a Server Component
export function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name}
          <FollowButton userId={user.id} />  {/* Client Component */}
        </li>
      ))}
    </ul>
  )
}
 
// FollowButton.tsx
'use client'
export function FollowButton({ userId }: { userId: string }) {
  const [isFollowing, setIsFollowing] = useState(false)
  return <button onClick={() => toggleFollow(userId)}>Follow</button>
}

In the good pattern, the <ul> and <li> elements render on the server as HTML. Only the button is hydrated on the client. This minimizes JavaScript shipped to the browser.

Data Fetching in Server Components

Server Components can fetch data directly without useEffect, loading states, or API routes:

// components/RecentPosts.tsx — Server Component
import { db } from '@/lib/db'
import { PostCard } from './PostCard'
import { Suspense } from 'react'
 
export async function RecentPosts() {
  const posts = await db.posts.findMany({
    orderBy: { publishedAt: 'desc' },
    take: 6,
    include: { author: true },
  })
 
  return (
    <section>
      <h2>Recent Posts</h2>
      <div className="grid grid-cols-3 gap-4">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>
    </section>
  )
}
 
// Use with Suspense for streaming
export function Page() {
  return (
    <div>
      <h1>Blog</h1>
      <Suspense fallback={<PostsSkeleton />}>
        <RecentPosts />
      </Suspense>
    </div>
  )
}

The await in RecentPosts pauses rendering of that component while allowing the rest of the page to stream. The <h1> renders immediately; RecentPosts streams in when the database query completes.

Server-Side Rendering Flow

Step-by-Step Implementation

Step 1: Organize Components by Boundary

Create a clear directory structure that separates Server and Client Components:

components/
  server/           # Server Components (default, no directive)
    UserList.tsx
    PostContent.tsx
    MetricsGrid.tsx
  client/           # Client Components ('use client')
    SearchBar.tsx
    ThemeToggle.tsx
    InteractiveChart.tsx
  shared/           # Components that work in both contexts
    Button.tsx
    Card.tsx

Step 2: Build a Server Component with Direct DB Access

// components/server/ProductCatalog.tsx
import { db } from '@/lib/db'
import { ProductCard } from '@/components/client/ProductCard'
import { formatPrice } from '@/lib/utils'
 
interface ProductCatalogProps {
  category: string
  page: number
  limit: number
}
 
export async function ProductCatalog({ category, page, limit }: ProductCatalogProps) {
  const [products, total] = await Promise.all([
    db.products.findMany({
      where: { category, published: true },
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
    }),
    db.products.count({ where: { category, published: true } }),
  ])
 
  return (
    <div>
      <div className="flex justify-between items-center mb-6">
        <h2 className="text-2xl font-bold capitalize">{category}</h2>
        <span className="text-gray-500">{total} products</span>
      </div>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {products.map(product => (
          <ProductCard
            key={product.id}
            id={product.id}
            name={product.name}
            price={formatPrice(product.price)}
            image={product.images[0]}
            rating={product.averageRating}
          />
        ))}
      </div>
      <Pagination currentPage={page} totalPages={Math.ceil(total / limit)} />
    </div>
  )
}

Step 3: Create Client Component Islands

// components/client/ProductCard.tsx
'use client'
 
import { useState } from 'react'
import Image from 'next/image'
import { useCart } from '@/hooks/useCart'
 
interface ProductCardProps {
  id: string
  name: string
  price: string
  image: string
  rating: number
}
 
export function ProductCard({ id, name, price, image, rating }: ProductCardProps) {
  const [isHovered, setIsHovered] = useState(false)
  const { addItem, isInCart } = useCart()
 
  return (
    <div
      className="border rounded-lg overflow-hidden transition-shadow hover:shadow-lg"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <Image src={image} alt={name} width={400} height={300} />
      <div className="p-4">
        <h3 className="font-semibold">{name}</h3>
        <div className="flex justify-between items-center mt-2">
          <span className="text-lg font-bold">{price}</span>
          <div className="flex items-center gap-1">
            <span>★</span>
            <span className="text-sm text-gray-600">{rating.toFixed(1)}</span>
          </div>
        </div>
        <button
          onClick={() => addItem({ id, name, price })}
          className={`mt-3 w-full py-2 rounded ${
            isInCart(id)
              ? 'bg-green-500 text-white'
              : 'bg-blue-600 text-white hover:bg-blue-700'
          }`}
        >
          {isInCart(id) ? 'In Cart' : 'Add to Cart'}
        </button>
      </div>
    </div>
  )
}

Step 4: Pass Server Data to Client Components

When passing complex data from Server to Client Components, ensure serialization:

// components/server/UserDashboard.tsx
import { db } from '@/lib/db'
import { UserSettings } from '@/components/client/UserSettings'
 
export async function UserDashboard({ userId }: { userId: string }) {
  const user = await db.users.findUnique({
    where: { id: userId },
    include: { preferences: true },
  })
 
  if (!user) return <div>User not found</div>
 
  // Pass only serializable data to Client Component
  return (
    <UserSettings
      userId={user.id}
      name={user.name}
      email={user.email}
      preferences={{
        theme: user.preferences.theme,
        language: user.preferences.language,
        notifications: user.preferences.notifications,
      }}
    />
  )
}

Step 5: Handle Loading with Suspense Boundaries

// app/products/page.tsx
import { Suspense } from 'react'
import { ProductCatalog } from '@/components/server/ProductCatalog'
import { CategorySidebar } from '@/components/server/CategorySidebar'
import { CatalogSkeleton, SidebarSkeleton } from '@/components/skeletons'
 
export default async function ProductsPage({
  searchParams,
}: {
  searchParams: { category?: string; page?: string }
}) {
  const { category = 'all', page = '1' } = await searchParams
 
  return (
    <div className="flex gap-8">
      <aside className="w-64">
        <Suspense fallback={<SidebarSkeleton />}>
          <CategorySidebar activeCategory={category} />
        </Suspense>
      </aside>
      <main className="flex-1">
        <Suspense fallback={<CatalogSkeleton />}>
          <ProductCatalog
            category={category}
            page={parseInt(page)}
            limit={12}
          />
        </Suspense>
      </main>
    </div>
  )
}

Real-World Use Cases

Use Case 1: E-Commerce Product Pages

Product pages have a perfect Server/Client split. The product description, images, specifications, and SEO metadata are Server Components—rendered once and cached. The add-to-cart button, quantity selector, and size picker are Client Components. This architecture minimizes JavaScript while maintaining full interactivity.

Use Case 2: Content Management Systems

CMS interfaces benefit enormously from Server Components. Article content, media libraries, and document trees render on the server with direct database access. Only the rich text editor, drag-and-drop interfaces, and toolbar controls need to be Client Components. The result is a CMS that loads instantly even with thousands of documents.

Use Case 3: Analytics Dashboards

Dashboard layouts with charts, tables, and filters use Server Components for the layout and data fetching, while chart libraries (which need canvas/WebGL) and interactive filters are Client Components. The data fetches happen server-side without exposing database credentials to the browser.

Use Case 4: Documentation Sites

Documentation sites are ideal Server Component applications. Content is static or infrequently updated, rendering is server-side for SEO, and the only client-side interactivity is search, table of contents navigation, and code block copy buttons.

Component Architecture

Best Practices for Production

  1. Default to Server Components: Every component in Next.js App Router is a Server Component unless explicitly marked with 'use client'. Only add the directive when you need interactivity, browser APIs, or hooks.

  2. Push Client Component boundaries deep: Instead of making an entire list a Client Component, make only the interactive items Client Components. The list container and static items stay as Server Components.

  3. Fetch data where it's used: Don't fetch data in a parent Server Component and pass it through multiple levels. Each Server Component can fetch its own data, and Next.js deduplicates identical requests within a render.

  4. Use 'use client' at the boundary, not the leaf: Place the directive in the file that first needs client features. All components imported into that file become part of the client bundle automatically.

  5. Keep props serializable: Pass primitives, plain objects, arrays, Dates, and JSX to Client Components. Avoid functions, class instances, Maps, Sets, and other non-serializable types.

  6. Use server-only package for server utilities: Import server-only in files that contain secrets or server-only code to get build-time errors if they're accidentally imported in Client Components.

  7. Leverage React's cache for deduplication: Wrap data-fetching functions in React.cache() so multiple components can call the same function without redundant database queries.

  8. Use Suspense strategically: Wrap each independently-loading Server Component in Suspense. This enables parallel streaming—multiple components load simultaneously rather than blocking each other.

  9. Profile the RSC payload size: Use browser DevTools to inspect the RSC payload. Large payloads mean too much data is being serialized. Consider fetching less data or paginating.

  10. Test Server Components with a real server: Server Components require a server environment. Use integration tests with next-server or test against a running dev server rather than trying to unit test in isolation.

Common Pitfalls and Solutions

PitfallImpactSolution
Adding 'use client' to the root layoutEntire app becomes client-rendered, losing all RSC benefitsKeep root layout as Server Component; only leaf/interactive components need the directive
Passing non-serializable props to Client ComponentsRuntime serialization errorPass only primitives, plain objects, arrays, Dates, and JSX; use JSON.parse(JSON.stringify(data)) to test
Using hooks in Server ComponentsBuild error—hooks are client-onlyMove the component to a Client Component file or restructure to avoid the hook
Fetching data in Client Components with useEffectWaterfall requests, loading spinners, poor performanceMove data fetching to the Server Component parent and pass data as props
Importing server-only code in Client ComponentsSecrets exposed in client bundleUse the server-only package to get build-time errors
Too many Suspense boundariesExcessive loading states, poor UXGroup related components under one Suspense boundary

Performance Optimization

Minimizing Client Bundle Size

The goal is to ship as little JavaScript as possible to the client:

// Check your bundle with next build output
// next.config.ts
const nextConfig = {
  experimental: {
    optimizePackageImports: ['lodash', 'date-fns', 'lucide-react'],
  },
}

For heavy Client Components, use dynamic imports:

import dynamic from 'next/dynamic'
 
const HeavyChart = dynamic(() => import('@/components/client/HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Skip server rendering for client-only libraries
})

Parallel Data Fetching in Server Components

Avoid waterfalls by fetching data in parallel:

// Bad: Sequential waterfalls
export async function Page() {
  const user = await getUser()           // 100ms
  const posts = await getPosts(user.id)  // 200ms (waits for user)
  const comments = await getComments()   // 150ms (waits for posts)
  // Total: 450ms
}
 
// Good: Parallel fetching
export async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // Total: 200ms (limited by slowest)
}

Comparison with Alternatives

FeatureServer ComponentsSSR (Traditional)CSR (Client-Side)
JavaScript BundleMinimal (only Client Components)Full React bundleFull React bundle
Data FetchingServer-side, direct DB accessServer-side during SSRClient-side (useEffect/fetch)
InteractivityOnly in Client ComponentsFullFull
SEOExcellent (server-rendered HTML)Good (server-rendered HTML)Poor (requires JS)
Time to InteractiveFast (less JS to hydrate)Slow (full hydration)Slow (full hydration + fetch)
Access to SecretsYes (server-only)Yes (during SSR)No
StreamingBuilt-in (Suspense)ManualN/A
CachingComponent-levelPage-levelClient-side only

Advanced Patterns

Server Component Composition with Children

Pass Server Components as children to Client Components to maintain server rendering:

// Client Component with Server Component children
'use client'
export function Tabs({ children }: { children: React.ReactNode }) {
  const [activeTab, setActiveTab] = useState(0)
 
  return (
    <div>
      <TabBar activeTab={activeTab} onChange={setActiveTab} />
      <div>{React.Children.toArray(children)[activeTab]}</div>
    </div>
  )
}
 
// Usage in a Server Component
export default async function Page() {
  return (
    <Tabs>
      <ServerComponentA />  {/* Remains server-rendered */}
      <ServerComponentB />  {/* Remains server-rendered */}
    </Tabs>
  )
}

The children prop is JSX (serializable), so the Server Components render on the server while the Tabs interactivity runs on the client.

Cross-Component Data Sharing

Use React's cache function to share data across Server Components without prop drilling:

// lib/data/user.ts
import { cache } from 'react'
 
export const getCurrentUser = cache(async () => {
  const session = await getSession()
  if (!session) return null
  return db.users.findUnique({ where: { id: session.userId } })
})
 
// Multiple Server Components can call this
// The database query executes only once per request
export async function Header() {
  const user = await getCurrentUser()
  return <header>{user ? `Welcome, ${user.name}` : 'Sign in'}</header>
}
 
export async function Sidebar() {
  const user = await getCurrentUser()
  return <aside>{user && <UserMenu user={user} />}</aside>
}

Testing Strategies

// __tests__/components/RecentPosts.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { RecentPosts } from '@/components/server/RecentPosts'
 
// Server Components can be tested by awaiting the component
describe('RecentPosts', () => {
  it('renders a list of recent posts', async () => {
    const RecentPostsComponent = await RecentPosts()
    render(RecentPostsComponent)
 
    expect(screen.getByText('Recent Posts')).toBeInTheDocument()
    expect(screen.getAllByRole('article')).toHaveLength(6)
  })
 
  it('displays author names', async () => {
    const component = await RecentPosts()
    render(component)
 
    expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
  })
})

Future Outlook

React Server Components are the foundation for React's future architecture. The React Compiler (formerly React Forget) will automatically optimize Server Components, eliminating the need for manual memoization. Selective hydration improvements will allow Client Components to hydrate independently, further reducing Time to Interactive.

The RSC ecosystem is growing rapidly. More libraries are shipping with Server Component support—data fetching libraries, UI component libraries, and form libraries are all adapting to the server-first model. The community is converging on patterns for organizing Server and Client Components, and tooling for analyzing RSC payloads and bundle sizes is improving.

Edge computing is a natural fit for Server Components. Running components at the edge means data fetching happens close to users, reducing latency. Edge-compatible databases and services are making it practical to deploy entire RSC applications to edge networks.

Conclusion

React Server Components fundamentally change how we think about React applications. By moving rendering to the server, they eliminate unnecessary client-side JavaScript, enable direct database access, and stream content progressively. The mental model—default to server, push client boundaries deep, keep props serializable—is simple once internalized.

Key takeaways:

  1. Server Components render on the server and never send their code to the browser
  2. Use 'use client' only for components that need interactivity, browser APIs, or hooks
  3. Props crossing the server-client boundary must be serializable (primitives, plain objects, JSX)
  4. Fetch data directly in Server Components without API routes or useEffect
  5. Use Suspense boundaries to enable parallel streaming of independent components
  6. Push Client Component boundaries as deep as possible to minimize JavaScript
  7. Use React's cache for request-level data deduplication across components

Start by auditing your current 'use client' directives. Many can be removed by restructuring components to isolate interactive elements. Each removed directive means less JavaScript shipped to the browser and a faster application for your users.