React Server Components Deep Dive: Architecture, Patterns, and Performance



React Server Components Deep Dive: Architecture, Patterns, and Performance

React Server Components (RSC) represent the most significant architectural change in React since Hooks. They fundamentally rethink where code runs, how data flows, and what gets sent to the browser.

This isn’t incremental improvement—it’s a paradigm shift.

Code on Laptop Photo by James Harrison on Unsplash

The Mental Model

Before: Everything Ships to the Browser

// Traditional React - ALL of this goes to the browser
import { format } from 'date-fns';          // 72KB
import { marked } from 'marked';             // 47KB
import { highlight } from 'prism-react-renderer'; // 35KB

function BlogPost({ slug }) {
  const [post, setPost] = useState(null);
  
  useEffect(() => {
    fetch(`/api/posts/${slug}`)
      .then(r => r.json())
      .then(setPost);
  }, [slug]);
  
  if (!post) return <Skeleton />;
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{format(post.date, 'PPP')}</time>
      <div dangerouslySetInnerHTML= />
    </article>
  );
}

Client bundle: 150KB+ just for libraries.

After: Server Does the Heavy Lifting

// Server Component - runs on server, sends HTML
import { format } from 'date-fns';
import { marked } from 'marked';
import { highlight } from 'prism-react-renderer';
import { db } from '@/lib/db';

async function BlogPost({ slug }) {
  const post = await db.posts.findUnique({ where: { slug } });
  
  if (!post) return notFound();
  
  const html = highlight(marked(post.content));
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{format(post.date, 'PPP')}</time>
      <div dangerouslySetInnerHTML= />
      <LikeButton postId={post.id} /> {/* Client Component */}
    </article>
  );
}

Client bundle: Only the LikeButton code ships to browser.

Server vs Client Components

Quick Reference

FeatureServer ComponentClient Component
async/await
useState/useEffect
Browser APIs
Event handlers
Direct DB access
Secrets/env vars❌ (exposed)
Large dependencies✅ (no bundle cost)❌ (adds to bundle)

The Boundary Pattern

// Server Component (default in App Router)
async function ProductPage({ id }) {
  const product = await getProduct(id);
  
  return (
    <div>
      <ProductDetails product={product} />
      
      {/* This boundary switches to client */}
      <AddToCartButton productId={id} />
    </div>
  );
}

// ProductDetails is also a Server Component
function ProductDetails({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      {/* Format price on server, no library sent to client */}
      <span>{formatCurrency(product.price)}</span>
    </div>
  );
}

// Client Component - needs interactivity
'use client';
import { useState } from 'react';
import { addToCart } from '@/actions/cart';

function AddToCartButton({ productId }) {
  const [pending, setPending] = useState(false);
  
  async function handleClick() {
    setPending(true);
    await addToCart(productId);
    setPending(false);
  }
  
  return (
    <button onClick={handleClick} disabled={pending}>
      {pending ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

React Code Photo by Lautaro Andreani on Unsplash

Data Fetching Patterns

Parallel Data Fetching

// ❌ Sequential - slow
async function Dashboard() {
  const user = await getUser();
  const posts = await getPosts(user.id);  // Waits for user
  const analytics = await getAnalytics(user.id);  // Waits for posts
  
  return <DashboardView user={user} posts={posts} analytics={analytics} />;
}

// ✅ Parallel - fast
async function Dashboard() {
  const user = await getUser();
  
  // Start both at the same time
  const [posts, analytics] = await Promise.all([
    getPosts(user.id),
    getAnalytics(user.id)
  ]);
  
  return <DashboardView user={user} posts={posts} analytics={analytics} />;
}

Streaming with Suspense

import { Suspense } from 'react';

async function Page() {
  return (
    <div>
      {/* Renders immediately */}
      <Header />
      
      {/* Streams in when ready */}
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />
      </Suspense>
      
      {/* Streams independently */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  );
}

// Each component can be slow independently
async function Posts() {
  const posts = await fetchPosts();  // 200ms
  return <PostList posts={posts} />;
}

async function Comments() {
  const comments = await fetchComments();  // 500ms
  return <CommentList comments={comments} />;
}

Preloading Data

// Initiate fetch before component renders
import { preload } from 'react-dom';
import { getUser, getPosts } from '@/lib/data';

function Page({ userId }) {
  // Start fetching immediately
  preload(getUser, userId);
  preload(getPosts, userId);
  
  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userId={userId} />
    </Suspense>
  );
}

Server Actions: Mutations Made Simple

// actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  // Direct database access - no API route needed
  await db.posts.create({
    data: { title, content, authorId: getCurrentUser().id }
  });
  
  // Revalidate cached data
  revalidatePath('/posts');
}

export async function likePost(postId: string) {
  await db.likes.create({
    data: { postId, userId: getCurrentUser().id }
  });
  
  revalidatePath(`/posts/${postId}`);
}
// Using in components
'use client';

import { createPost, likePost } from '@/actions';
import { useActionState } from 'react';

function CreatePostForm() {
  const [state, formAction, pending] = useActionState(createPost);
  
  return (
    <form action={formAction}>
      <input name="title" required />
      <textarea name="content" required />
      <button disabled={pending}>
        {pending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

function LikeButton({ postId }) {
  const likeWithId = likePost.bind(null, postId);
  
  return (
    <form action={likeWithId}>
      <button type="submit">❤️ Like</button>
    </form>
  );
}

Composition Patterns

Passing Server Components as Props

// Server Component wrapper
async function DataProvider({ children }) {
  const data = await fetchExpensiveData();
  
  return (
    <DataContext.Provider value={data}>
      {children}
    </DataContext.Provider>
  );
}

// Can render Client Components inside
function Page() {
  return (
    <DataProvider>
      <InteractiveWidget /> {/* Client Component */}
    </DataProvider>
  );
}

The Slot Pattern

// Server Component with client "slots"
async function ProductCard({ product, actions }) {
  const details = await getProductDetails(product.id);
  
  return (
    <div className="card">
      <img src={details.image} alt={product.name} />
      <h2>{product.name}</h2>
      <p>{details.description}</p>
      
      {/* Client Component passed as prop */}
      {actions}
    </div>
  );
}

// Usage
<ProductCard 
  product={product}
  actions={<AddToCartButton productId={product.id} />}
/>

Caching Strategies

Request Memoization

// Same request in multiple components = one fetch
async function Layout({ children }) {
  const user = await getUser();  // Fetch #1
  return (
    <div>
      <Header user={user} />
      {children}
    </div>
  );
}

async function Header({ user }) {
  const user = await getUser();  // Deduplicated! Uses cached result
  return <nav>{user.name}</nav>;
}

Static and Dynamic Rendering

// Force dynamic (no caching)
export const dynamic = 'force-dynamic';

// Or per-request
import { unstable_noStore as noStore } from 'next/cache';

async function LiveData() {
  noStore();  // Always fresh
  const data = await fetchLiveData();
  return <div>{data}</div>;
}

// Time-based revalidation
async function Posts() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 }  // Revalidate every hour
  });
  return <PostList posts={posts} />;
}

Performance Impact

Real-world metrics from production migrations:

MetricCSRSSRRSC
FCP2.1s1.2s0.8s
LCP3.5s1.8s1.1s
TTI4.2s3.1s1.4s
JS Bundle450KB450KB120KB

Common Mistakes

1. Over-using ‘use client’

// ❌ Marks entire subtree as client
'use client';

function Page() {
  return (
    <div>
      <StaticContent />  {/* Now also client! */}
      <InteractiveButton />
    </div>
  );
}

// ✅ Only mark what needs interactivity
function Page() {
  return (
    <div>
      <StaticContent />  {/* Server Component */}
      <InteractiveButton />  {/* Has 'use client' */}
    </div>
  );
}

2. Serialization Boundaries

// ❌ Can't pass functions to Client Components
async function Page() {
  const handleClick = () => console.log('clicked');
  
  return <Button onClick={handleClick} />;  // Error!
}

// ✅ Use Server Actions instead
async function Page() {
  async function handleSubmit() {
    'use server';
    console.log('submitted');
  }
  
  return <Form action={handleSubmit} />;
}

Conclusion

React Server Components are not just an optimization—they’re a fundamental rethinking of where React code runs. By moving rendering to the server where possible:

  • Bundle sizes shrink dramatically
  • Data fetching simplifies
  • Sensitive logic stays secure
  • Performance improves by default

The learning curve is real, but the benefits are substantial. Start by identifying components that don’t need interactivity, and gradually migrate them to Server Components.

The future of React is hybrid, and RSC is how we get there.


Have you migrated to Server Components? Share your experience in the comments.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)