VD.
react2026-01-2225 min

Understanding React Server Components: Architecture, Patterns & Best Practices

A deep dive into React Server Components covering the RSC protocol, streaming architecture, server actions, and best practices for building performant Next.js applications.

Understanding React Server Components: Architecture, Patterns & Best Practices

I recently came across Dan Abramov's RSC Explorer and his accompanying blog post. Stepping through the RSC protocol line by line made everything click. This inspired me to write a comprehensive guide covering everything I've learned about React Server Components.

In this deep dive, we'll explore how RSC works under the hood, from the wire protocol to streaming architecture, and learn patterns that help build performant Next.js applications.

The Evolution: SSR → CSR → RSC

To understand what problem RSC solves, let's trace the evolution of React rendering:

Server-Side Rendering (SSR)

Traditional SSR renders React components to HTML on the server:

Browser Request → Server renders HTML → Send to browser → Download JS → Hydrate entire app

The problem: Even though users see HTML immediately, the app isn't interactive until the entire JavaScript bundle downloads and hydrates. For large apps, this "time to interactive" gap is painful.

Client-Side Rendering (CSR)

Pure SPAs render everything in the browser:

Browser Request → Send empty HTML + JS → Download JS → Render in browser

The problem: Users see a blank screen until JavaScript loads and executes. Terrible for SEO and first paint metrics.

React Server Components (RSC)

RSC introduces a fundamentally different model:

Browser Request → Server renders RSC → Stream as RSC payload → Client reconstructs UI

The key insight: Some components never need to be interactive. Why ship their JavaScript to the browser at all?

SSR vs RSC: Key Differences

Let's clarify a common misconception: RSC is not a replacement for SSR. They work together.

AspectTraditional SSRReact Server Components
OutputHTML stringRSC payload (serialized React tree)
HydrationEntire app hydratesOnly client components hydrate
Component JSAll components shipped to browserServer components = 0KB client JS
Re-renderingRe-render requires new page load (or client hydration)Can refetch server components without losing client state
Data FetchingDouble data fetching (server render + client hydration)Server components fetch once, no client duplication

The Mental Model Shift

Think of your component tree as having two types of nodes:

  • Server Components (default): Run only on the server. Can use async/await, access databases directly, read files. Zero JavaScript shipped to client.

  • Client Components (marked with "use client"): Run on both server (for SSR) and client. Can use hooks, event handlers, browser APIs.

// This is a Server Component (default)
async function BlogPost({ slug }) {
  // Direct database access - no API needed!
  const post = await db.posts.findUnique({ where: { slug } });
  
  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />
      {/* Client component for interactivity */}
      <LikeButton postId={post.id} initialCount={post.likes} />
    </article>
  );
}
// LikeButton.tsx
"use client";

import { useState } from 'react';

export function LikeButton({ postId, initialCount }) {
  const [count, setCount] = useState(initialCount);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      ❤️ {count}
    </button>
  );
}
IMPORTANT

"use client" does NOT mean "client only". Client components are still server-side rendered for the initial HTML. The directive marks the boundary where hydration begins.


How Streaming SSR Works

One of RSC's superpowers is streaming. Instead of waiting for the entire page to render, Next.js streams content as it becomes ready. Let's understand this with a practical example.

What Are Async Components?

Unlike traditional React components, Server Components can be async functions. This means they can directly await data fetches, database queries, or any asynchronous operation:

// A simple async server component
async function UserProfile({ userId }) {
  const user = await db.users.findUnique({ where: { id: userId } });
  return <div>{user.name}</div>;
}

No useEffect, no loading states, no data fetching libraries. Just async/await. This is powerful, but what happens when the async operation is slow?

The Problem: Slow Async Components Block Rendering

Consider this component that simulates a slow database query:

// SlowComponent.tsx
async function SlowComponent() {
  // Simulate a slow database query or API call
  await new Promise((resolve) => setTimeout(resolve, 3000));
  
  return (
    <div>
      This is a slow component that took 3 seconds to load.
    </div>
  );
}

export default SlowComponent;

Now, if you render this component directly in a page:

// page.tsx - Without Suspense
export default async function Page() {
  return (
    <div>
      <h1>Welcome to my site!</h1>
      <SlowComponent />
      <footer>Footer content</footer>
    </div>
  );
}

The problem: The entire page waits 3 seconds before ANY HTML is sent to the browser. The user stares at a blank screen.

The Solution: Suspense Enables Streaming

Wrap the slow component in <Suspense> to unlock streaming:

// page.tsx - With Suspense
import { Suspense } from 'react';

export default async function Page() {
  return (
    <div>
      <h1>Welcome to my site!</h1>
      
      <Suspense fallback={<div>Loading slow component...</div>}>
        <SlowComponent />
      </Suspense>
      
      <footer>Footer content</footer>
    </div>
  );
}

Now what happens:

  1. Browser receives the header and footer immediately
  2. The loading fallback shows in place of SlowComponent
  3. After 3 seconds, the actual content streams in and replaces the fallback
  4. No full page reload - React swaps the content seamlessly

HTTP Chunked Transfer Encoding

Under the hood, streaming uses HTTP's chunked transfer encoding:

HTTP/1.1 200 OK
Transfer-Encoding: chunked

<html><head>...</head><body>
<h1>Welcome to my site!</h1>
<div>Loading slow component...</div>
<footer>Footer content</footer>
<!-- Connection stays open... -->

The browser receives and renders this immediately. Then, 3 seconds later:

<!-- More chunks arrive -->
<div hidden id="S:0">
  <div>This is a slow component that took 3 seconds to load.</div>
</div>
<script>$RC("B:0", "S:0")</script>
</body></html>

The browser can start parsing and rendering HTML while more chunks are still arriving.

The Streaming Protocol: Placeholders and Swaps

When React streams content, it uses special comment markers in the HTML:

<!-- Initial HTML sent immediately -->
<h1>Welcome to my site!</h1>

<!--$?-->
<template id="B:0"></template>
<div>Loading slow component...</div>
<!--/$-->

<footer>Footer content</footer>
  • <!--$?--> - Marks a Suspense boundary with pending content
  • <template id="B:0"> - Placeholder that will be replaced
  • <!--/$--> - End of the Suspense boundary

When the slow content resolves, React streams additional HTML:

<div hidden id="S:0">
  <div>This is a slow component that took 3 seconds to load.</div>
</div>
<script>$RC("B:0", "S:0")</script>

The $RC (React Client) function swaps the placeholder with the actual content:

// Simplified version of what $RC does
function $RC(boundaryId, contentId) {
  const boundary = document.getElementById(boundaryId);
  const content = document.getElementById(contentId);
  
  // Replace the fallback with actual content
  boundary.replaceWith(content.content);
  content.remove();
}

Multiple Suspense Boundaries

You can have multiple independent Suspense boundaries, each streaming when ready:

async function Page() {
  return (
    <div>
      <Header /> {/* Renders immediately */}
      
      <Suspense fallback={<ProductsSkeleton />}>
        <ProductList /> {/* 500ms - streams first */}
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsList /> {/* 2000ms - streams second */}
      </Suspense>
      
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations /> {/* 3000ms - streams last */}
      </Suspense>
      
      <Footer /> {/* Renders immediately */}
    </div>
  );
}

Each component streams in as soon as it's ready. No component blocks another.

Core Web Vitals Impact

Streaming dramatically improves Core Web Vitals:

MetricWithout SuspenseWith Suspense + Streaming
FCP (First Contentful Paint)3+ seconds (waits for slowest component)~100ms (immediate)
LCP (Largest Contentful Paint)Blocked by slow componentsHero content loads first
TTI (Time to Interactive)Waits for everythingProgressive - fast parts work immediately

The RSC Payload Format (Flight Protocol)

When you navigate client-side in a Next.js app (via <Link> or router.push), you don't get HTML. You get an RSC payload. This is the Flight protocol - React's wire format for sending the rendered output of server components over the network.

NOTE

Initial page loads still return HTML (for SEO and fast first paint), with the RSC payload embedded in script tags. Client-side navigations fetch pure RSC payloads.

How Client-Side Navigation Works

Unlike traditional page navigations, clicking a <Link> doesn't reload the page. Here's what happens:

  1. No browser refresh - the page stays mounted
  2. Fetch only what changed - Next.js requests the RSC payload for just the new route segment
  3. React reconciles - compares the new payload with the current component tree
  4. Partial update - only the changed parts re-render; shared layouts stay in place
/dashboard          →    /dashboard/settings
┌──────────────────┐     ┌──────────────────┐
│ Header (layout)  │     │ Header (cached)  │  ← Not refetched
├──────────────────┤     ├──────────────────┤
│ Sidebar (layout) │     │ Sidebar (cached) │  ← Not refetched  
├──────────────────┤     ├──────────────────┤
│ Page A content   │  →  │ Page B content   │  ← Only this fetched
└──────────────────┘     └──────────────────┘

This means:

  • Layouts are preserved - shared UI (headers, sidebars) isn't re-rendered
  • Client state survives - form inputs, scroll position, and component state in layouts persist
  • Faster navigations - smaller payloads since only page segments are fetched

Open your browser's Network tab and look for requests with Accept: text/x-component or RSC: 1 headers. Let's decode what you'll see.

Basic Elements: Server Components to Flight Format

Consider this simple server component:

// A simple server component
async function Greeting() {
  return (
    <div>
      <h1>Hello World</h1>
    </div>
  );
}

This produces the following RSC payload:

0:["$","div",null,{"children":[["$","h1",null,{"children":"Hello World"}]]}]

Let's break down the format:

// Format: [type, elementType, key, props]
["$", "div", null, { 
  children: [
    ["$", "h1", null, { children: "Hello World" }]
  ]
}]
  • "$" - Indicates this is a React element
  • "div" - The element type (string tag or component reference)
  • null - The React key
  • {...} - Props including children

Async Components: The $L Notation

When a component is async or lazy-loaded, React uses lazy references to stream content progressively:

// An async component that fetches data
async function BlogPost({ slug }) {
  const post = await db.posts.findUnique({ where: { slug } });
  
  return (
    <div className="post">
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

// Parent component rendering it
async function Page() {
  return <BlogPost slug="hello-world" />;
}

The RSC payload streams in multiple lines:

// Line 1: References $L2 - "lazy load chunk #2 when it arrives"
1:["$","$L2",null,{"slug":"hello-world"}]

// Line 2: The actual resolved content (arrives later)
2:["$","div",null,{"className":"post","children":[["$","h1",null,{"children":"My First Post"}],["$","p",null,{"children":"Post content here..."}]]}]

The $L2 means "this is a lazy reference to chunk #2". React waits for line 2: to arrive in the stream, then substitutes it in place. This is how streaming works: React can send the page structure immediately while async content resolves.

Client Components: The I Notation

Client components are referenced by their module path, not serialized. Here's a server component that renders a client component:

// Server Component
async function PostPage({ slug }) {
  const post = await db.posts.findUnique({ where: { slug } });
  
  return (
    <article>
      <h1>{post.title}</h1>
      {/* LikeButton is a client component */}
      <LikeButton postId={post.id} initialCount={post.likes} />
    </article>
  );
}
// LikeButton.tsx - Client Component
"use client";

import { useState } from 'react';

export function LikeButton({ postId, initialCount }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(c => c + 1)}>❤️ {count}</button>;
}

This produces:

// Line 0: Client component IMPORT declaration
0:I["(app)/components/LikeButton.tsx",["default"],"LikeButton"]

// Line 1: The component tree - notice $L0 references the import
1:["$","article",null,{"children":[["$","h1",null,{"children":"My Post"}],["$","$L0",null,{"postId":123,"initialCount":5}]]}]
Key insight: Key insight: The I[...] line declares a client module import. The component code itself lives in a separate JavaScript bundle that the browser loads. The RSC payload just says "render this client component with these props."
TIP

You can explore RSC payloads interactively at rscexplorer.dev. It's an excellent way to build intuition for the protocol.

Content Negotiation

Next.js uses content negotiation to decide what format to send:

Request HeaderResponse
Accept: text/html (initial page load)Full HTML with embedded RSC payload
Accept: text/x-component (client navigation)Pure RSC payload
RSC: 1RSC payload
Next-Router-State-Tree: ...Partial RSC payload (layout stays cached)

Server Actions Deep Dive

Server Actions let you define server-side functions that can be called directly from client components.

The "use server" Directive

// actions.ts
"use server";

export async function createPost(formData: FormData) {
  const title = formData.get("title");
  const content = formData.get("content");
  
  const post = await db.posts.create({
    data: { title, content }
  });
  
  revalidatePath("/posts");
  return { success: true, postId: post.id };
}

How Forms Work: Hidden Inputs

When you use a server action with a form, Next.js adds hidden inputs:

"use client";

import { createPost } from "./actions";

export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <textarea name="content" />
      <button type="submit">Create</button>
    </form>
  );
}

The rendered HTML includes:

<form action="" method="POST">
  <input type="hidden" name="$ACTION_ID" value="abc123..." />
  <input name="title" />
  <textarea name="content"></textarea>
  <button type="submit">Create</button>
</form>

The $ACTION_ID tells the server which action to invoke.

Progressive Enhancement

Server Actions work without JavaScript. This is progressive enhancement in action:

  1. Without JS: Form submits traditionally, server processes, returns new page
  2. With JS: React intercepts, sends fetch request, updates UI without reload

Without JavaScript:

With JavaScript:

useActionState Pattern

For forms that need loading states and error handling:

"use client";

import { useActionState } from "react";
import { createPost } from "./actions";

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);
  
  return (
    <form action={formAction}>
      <input name="title" disabled={isPending} />
      <textarea name="content" disabled={isPending} />
      
      {state?.error && <p className="error">{state.error}</p>}
      
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Returning JSX from Server Actions

Server actions can return JSX, which React will render:

"use server";

export async function loadMorePosts(offset: number) {
  const posts = await db.posts.findMany({
    skip: offset,
    take: 10
  });
  
  // Return JSX directly!
  return (
    <>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </>
  );
}
"use client";

import { useState, useTransition } from "react";
import { loadMorePosts } from "./actions";

export function PostList({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts);
  const [offset, setOffset] = useState(10);
  const [isPending, startTransition] = useTransition();
  
  async function handleLoadMore() {
    startTransition(async () => {
      const newPosts = await loadMorePosts(offset);
      setPosts(prev => [...prev, newPosts]);
      setOffset(prev => prev + 10);
    });
  }
  
  return (
    <div>
      {posts}
      <button onClick={handleLoadMore} disabled={isPending}>
        {isPending ? "Loading..." : "Load More"}
      </button>
    </div>
  );
}

Client vs Server Component Boundaries

Understanding boundaries is crucial for performance.

"use client" Does NOT Mean "Client Only"

A common misconception. When you mark a component with "use client":

  1. Initial request: Server still SSRs it to HTML
  2. Hydration: Component hydrates and becomes interactive
  3. Subsequent renders: Component runs on client

The directive marks where the hydration boundary begins, not where server rendering stops.

What Can Cross the Boundary

Props passed from Server to Client components must be serializable:

✅ Can Pass❌ Cannot Pass
Strings, numbers, booleansFunctions (unless server actions)
Arrays, plain objectsClasses, instances
Dates (serialized)Symbols
null, undefinedDOM nodes
Server ActionsPromises (use use() hook instead)
// ❌ This will error
async function ServerComponent() {
  const handleClick = () => console.log("clicked"); // Function!
  
  return <ClientComponent onClick={handleClick} />; // Error!
}

// ✅ Use server actions instead
async function ServerComponent() {
  async function handleSubmit(formData: FormData) {
    "use server";
    // Process on server
  }
  
  return <ClientComponent onSubmit={handleSubmit} />; // Works!
}

Boundary Placement Strategy

Push "use client" as far down the tree as possible:

// ❌ Poor: Entire page becomes client component
"use client";

export function ProductPage({ product }) {
  const [quantity, setQuantity] = useState(1);
  
  return (
    <div>
      <ProductImages images={product.images} />  {/* Could be server! */}
      <ProductDescription desc={product.description} />  {/* Could be server! */}
      <ProductReviews reviews={product.reviews} />  {/* Could be server! */}
      <QuantityPicker value={quantity} onChange={setQuantity} />  {/* Needs client */}
    </div>
  );
}
// ✅ Better: Only interactive parts are client components
// ProductPage.tsx (Server Component)
export function ProductPage({ product }) {
  return (
    <div>
      <ProductImages images={product.images} />
      <ProductDescription desc={product.description} />
      <ProductReviews reviews={product.reviews} />
      <QuantityPicker initialValue={1} />  {/* Only this is client */}
    </div>
  );
}

// QuantityPicker.tsx
"use client";
export function QuantityPicker({ initialValue }) {
  const [quantity, setQuantity] = useState(initialValue);
  return <input type="number" value={quantity} onChange={e => setQuantity(e.target.value)} />;
}

Composition Pattern: Server Component with Client Children

You can pass Server Components as children to Client Components:

// Server Component
async function Dashboard() {
  const data = await fetchDashboardData();
  
  return (
    <ClientTabs>
      {/* These are server components passed as children! */}
      <OverviewTab data={data.overview} />
      <AnalyticsTab data={data.analytics} />
      <SettingsTab data={data.settings} />
    </ClientTabs>
  );
}

// ClientTabs.tsx
"use client";
export function ClientTabs({ children }) {
  const [activeTab, setActiveTab] = useState(0);
  const tabs = Children.toArray(children);
  
  return (
    <div>
      <div className="tab-bar">
        {tabs.map((_, i) => (
          <button key={i} onClick={() => setActiveTab(i)}>
            Tab {i + 1}
          </button>
        ))}
      </div>
      <div className="tab-content">
        {tabs[activeTab]}
      </div>
    </div>
  );
}

The server-rendered children are passed through without re-rendering.


Bundle Size Impact

This is where RSC really shines.

Server Components = 0KB Client JS

Any component that stays as a Server Component contributes zero bytes to your JavaScript bundle:

// This entire component is FREE from a bundle perspective
async function BlogPost({ slug }) {
  // Even these heavy markdown libraries don't ship to client
  const { unified } = await import("unified");
  const { remarkParse } = await import("remark-parse");
  const { remarkHtml } = await import("remark-html");
  
  const post = await db.posts.findUnique({ where: { slug } });
  const html = await unified()
    .use(remarkParse)
    .use(remarkHtml)
    .process(post.content);
  
  return <article dangerouslySetInnerHTML={{ __html: html }} />;
}

Those markdown libraries might be 50KB+ combined, but they're never sent to the browser.

Auditing Your Bundle

Use Next.js's built-in bundle analyzer:

npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // your config
});
ANALYZE=true npm run build

Look for Client Components that could be Server Components. Common candidates:

  • Static content displays
  • Data fetching components
  • Formatting utilities

Advanced Patterns

Infinite Scroll with RSC

Combine client state management with server-rendered content:

// ServerPostList.tsx (Server Component)
async function ServerPostList({ page }) {
  const posts = await db.posts.findMany({
    skip: page * 10,
    take: 10
  });
  
  return (
    <>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </>
  );
}

// InfiniteScroll.tsx
"use client";

import { useState, useEffect, useRef } from "react";
import { useInView } from "react-intersection-observer";

export function InfiniteScroll({ initialContent, loadMore }) {
  const [pages, setPages] = useState([initialContent]);
  const [page, setPage] = useState(1);
  const { ref, inView } = useInView();
  
  useEffect(() => {
    if (inView) {
      loadMore(page).then(newContent => {
        setPages(prev => [...prev, newContent]);
        setPage(prev => prev + 1);
      });
    }
  }, [inView]);
  
  return (
    <div>
      {pages}
      <div ref={ref}>Loading more...</div>
    </div>
  );
}

The use() Hook with Promises

React 19's use() hook lets you unwrap promises in render:

// Parent passes a promise
async function Page() {
  const dataPromise = fetchData(); // Don't await!
  
  return (
    <Suspense fallback={<Loading />}>
      <DataDisplay dataPromise={dataPromise} />
    </Suspense>
  );
}

// Child unwraps the promise
"use client";
import { use } from "react";

function DataDisplay({ dataPromise }) {
  const data = use(dataPromise); // Suspends until resolved
  return <div>{data.title}</div>;
}

useTransition for Non-Blocking Updates

Keep the UI responsive during heavy updates:

"use client";

import { useState, useTransition } from "react";
import { searchPosts } from "./actions";

export function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  
  function handleSearch(e) {
    const value = e.target.value;
    setQuery(value); // High priority: update input immediately
    
    startTransition(async () => {
      // Low priority: can be interrupted
      const newResults = await searchPosts(value);
      setResults(newResults);
    });
  }
  
  return (
    <div>
      <input 
        value={query} 
        onChange={handleSearch}
        placeholder="Search posts..."
      />
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        {results.map(post => <PostCard key={post.id} post={post} />)}
      </div>
    </div>
  );
}

Best Practices

1. Keep Data Fetching in Server Components

// ✅ Server component fetches data
async function ProductPage({ id }) {
  const product = await db.products.findUnique({ where: { id } });
  return <ProductDisplay product={product} />;
}

// ❌ Client component fetching
"use client";
function ProductPage({ id }) {
  const [product, setProduct] = useState(null);
  
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(r => r.json())
      .then(setProduct);
  }, [id]);
  
  return product ? <ProductDisplay product={product} /> : <Loading />;
}

2. Push "use client" Down

Isolate interactivity to the smallest possible components.

3. Use Suspense Around Slow Components

async function Page() {
  return (
    <div>
      <FastHeader />
      
      <Suspense fallback={<ProductsSkeleton />}>
        <SlowProductsQuery />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <SlowerReviewsQuery />
      </Suspense>
    </div>
  );
}

4. Don't Pass Non-Serializable Props

If you need to pass a function, use a Server Action.

5. Prefer Server Actions Over API Routes

For mutations (creating, updating, deleting data), Server Actions are preferred over traditional API routes:

// ❌ Traditional API route approach
// 1. Create app/api/posts/route.ts
// 2. Write POST handler with validation
// 3. Client fetches with error handling
// 4. Manually revalidate cache

// ✅ Server Action approach
"use server";
export async function createPost(formData: FormData) {
  const post = await db.posts.create({ 
    data: { title: formData.get("title") } 
  });
  revalidatePath("/posts");
  return post;
}

Why Server Actions are better:

AspectAPI RoutesServer Actions
Type safetyManual types, no end-to-end safetyFull TypeScript from client to server
BoilerplateRoute file + fetch + error handlingJust call the function
Progressive enhancementRequires JavaScriptWorks without JS (forms)
RevalidationManual cache invalidationBuilt-in revalidatePath/revalidateTag
Bundle sizeClient-side fetch logicZero client code

Common Pitfalls

1. Using Hooks in Server Components

// ❌ This will error
async function ServerComponent() {
  const [state, setState] = useState(0); // Error: hooks only in client
  const theme = useContext(ThemeContext); // Error!
  
  useEffect(() => {}, []); // Error!
}

2. Passing Functions Across Boundaries

// ❌ Functions aren't serializable
<ClientComponent onClick={() => console.log("click")} />

// ✅ Use server actions
async function handleClick() {
  "use server";
  console.log("click");
}
<ClientComponent onClick={handleClick} />

3. Not Understanding Hydration Mismatches

Client components render on both server and client. If they produce different output, you get hydration errors:

"use client";

function TimeDisplay() {
  // ❌ Different on server vs client!
  return <span>{new Date().toLocaleTimeString()}</span>;
}

// ✅ Use useEffect for client-only values
function TimeDisplay() {
  const [time, setTime] = useState<string | null>(null);
  
  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);
  
  return <span>{time ?? "Loading..."}</span>;
}

4. Over-Using "use client"

Don't add "use client" "just in case". Every client component adds to your bundle.


Performance Checklist

Before shipping, verify:

  • Critical content is outside Suspense - Hero, headlines, and key CTAs render immediately
  • Client components are leaf nodes - Push "use client" down as far as possible
  • Server components fetch their own data - Colocate data fetching with components that use it
  • Streaming is used for slow operations - Wrap slow queries in Suspense
  • Error boundaries are in place - Graceful degradation for failed fetches
  • Bundle size audited - Run the bundle analyzer, identify unnecessary client components
  • No serialization errors - All props crossing the boundary are serializable
  • Server Actions used for mutations - Prefer over API routes

Conclusion

React Server Components represent a fundamental shift in how we build React applications. By understanding the RSC protocol, streaming architecture, and component boundaries, you can build applications that are both faster and simpler.

The key mental model: Think about where code needs to run. Most of your application probably doesn't need client-side interactivity. Keep it on the server, ship less JavaScript, and let React handle the complexity of stitching it all together.

I highly recommend playing with RSC Explorer to build intuition for the protocol. Seeing the RSC payload stream line by line makes the architecture tangible in a way that reading articles (including this one!) never quite achieves.

VD

Vikram Dokkupalle

Frontend Engineer & UI/UX Enthusiast. Passionate about React, performance, and clean design.

Loading comments...

More from react

View all posts
2026-01-2520 min

Frontend Security in React: Vulnerabilities, Protections & Best Practices

A comprehensive guide to frontend security covering XSS, CSRF, injection attacks, and CSP. Learn how React protects you automatically and where you still need to be vigilant.

2026-01-1315 min

React Context Deep Dive: Avoiding Re-renders and Advanced Patterns

Master React Context with this deep dive into how it works internally, why it causes re-renders, and proven patterns to optimize performance in production applications.

2026-01-1312 min

SOLID Principles in React: Building Better Components

Learn how to apply SOLID principles from object-oriented design to build more maintainable, scalable, and testable React applications.