Web Dev

React Server Components: A Deep Dive

Harshit Rathod
#react#nextjs#rsc#frontend

React Server Components (RSC) fundamentally change how we think about building React apps. They’re not just a performance optimization — they’re a new mental model for where code runs.

React code displayed on a modern monitor with developer tools open

The Core Idea

Traditional React: everything runs on the client.

Browser loads JS → React hydrates → Components render → User sees content

With RSC: some components run on the server, some on the client.

Server renders RSC → Streams HTML + RSC payload → Client hydrates only interactive parts

The result? Less JavaScript shipped, faster initial load, and direct access to backend resources.


Server vs Client Components

Here’s the decision framework I use:

Use Server Component When…Use Client Component When…
Fetching dataHandling user interactions
Accessing backend resourcesUsing browser APIs
Keeping secrets server-sideManaging local state
Rendering static contentUsing effects or refs
Large dependencies (markdown, syntax highlighting)Animations

Server Component Example

// app/blog/[slug]/page.tsx
// This is a Server Component by default (no "use client")

import { db } from "@/lib/database";
import { formatDate } from "@/lib/utils";
import { CommentSection } from "./comment-section"; // Client component

interface Props {
  params: { slug: string };
}

export default async function BlogPost({ params }: Props) {
  // Direct database access — no API needed!
  const post = await db.posts.findUnique({
    where: { slug: params.slug },
    include: { author: true, tags: true },
  });

  if (!post) return notFound();

  return (
    <article className="max-w-3xl mx-auto">
      <header>
        <span className="text-sm text-gray-500">
          {formatDate(post.publishedAt)}
        </span>
        <h1 className="text-4xl font-bold mt-2">{post.title}</h1>
        <div className="flex items-center gap-3 mt-4">
          <img
            src={post.author.avatar}
            alt={post.author.name}
            className="w-10 h-10 rounded-full"
          />
          <span className="text-gray-700">{post.author.name}</span>
        </div>
      </header>

      <div className="prose mt-8" dangerouslySetInnerHTML={{
        __html: post.contentHtml
      }} />

      {/* Client component for interactivity */}
      <CommentSection postId={post.id} />
    </article>
  );
}

Client Component Example

"use client";
// app/blog/[slug]/comment-section.tsx

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

interface Props {
  postId: string;
}

export function CommentSection({ postId }: Props) {
  const [comment, setComment] = useState("");
  const [isPending, startTransition] = useTransition();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    startTransition(async () => {
      await submitComment(postId, comment);
      setComment("");
    });
  };

  return (
    <section className="mt-12 border-t pt-8">
      <h2 className="text-2xl font-bold">Comments</h2>

      <form onSubmit={handleSubmit} className="mt-4">
        <textarea
          value={comment}
          onChange={(e) => setComment(e.target.value)}
          placeholder="Write a comment..."
          className="w-full p-3 border rounded-lg resize-none"
          rows={4}
        />
        <button
          type="submit"
          disabled={isPending}
          className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg
                     disabled:opacity-50"
        >
          {isPending ? "Posting..." : "Post Comment"}
        </button>
      </form>
    </section>
  );
}

Notice the "use client" directive at the top — this is the boundary marker.

Streaming and Suspense

One of the most powerful features of RSC is streaming. Instead of waiting for all data to load, the server streams HTML as it becomes available:

import { Suspense } from "react";

export default function Dashboard() {
  return (
    <div className="grid grid-cols-3 gap-6">
      {/* These load independently and stream in as ready */}
      <Suspense fallback={<Skeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <RecentOrders />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <UserActivity />
      </Suspense>
    </div>
  );
}

async function RevenueChart() {
  // This might take 2 seconds...
  const data = await fetchRevenueData();
  return <Chart data={data} />;
}

async function RecentOrders() {
  // This might take 500ms...
  const orders = await fetchRecentOrders();
  return <OrderList orders={orders} />;
}

The browser shows each section as soon as it’s ready, not after the slowest one finishes.

Streaming Timeline

Time  0ms   200ms   500ms    1000ms   2000ms
       │      │       │         │        │
       ▼      ▼       ▼         ▼        ▼
      Shell  Nav    Orders    Activity  Revenue
      ████   ████   ████████  ████████  ████████████
                     ▲ streams in       ▲ streams in

A whiteboard showing component architecture diagrams with arrows and boxes

Data Fetching Patterns

Pattern 1: Parallel Fetching

// ✅ Good: Fetch in parallel
async function Page() {
  const [posts, categories, user] = await Promise.all([
    fetchPosts(),
    fetchCategories(),
    fetchUser(),
  ]);

  return <Dashboard posts={posts} categories={categories} user={user} />;
}

Pattern 2: Waterfall (Avoid This)

// ❌ Bad: Sequential waterfall
async function Page() {
  const user = await fetchUser();           // 200ms
  const posts = await fetchPosts(user.id);  // 300ms (waits for user)
  const comments = await fetchComments();    // 150ms (waits for posts)
  // Total: 650ms
}

Pattern 3: Preloading

// ✅ Better: Preload pattern
import { preload } from "./data";

export default async function Page({ params }) {
  // Start fetching immediately — don't await yet
  preload(params.id);

  // Do other work...
  const config = await getConfig();

  // Now use the preloaded data
  const data = await getData(params.id); // Already cached!

  return <Component data={data} config={config} />;
}

Performance Benchmarks

I benchmarked a real blog app with and without RSC:

MetricClient-SideRSCImprovement
First Contentful Paint1.8s0.6s67% faster
Largest Contentful Paint3.2s1.1s66% faster
Total JS Bundle245 KB89 KB64% smaller
Time to Interactive4.1s1.4s66% faster
Lighthouse Score7296+24 points

These numbers are from a content-heavy blog with syntax highlighting, markdown rendering, and an image gallery. The more static content you have, the bigger the RSC wins.

Performance dashboard showing web vitals metrics and loading times

Common Mistakes

1. Making Everything a Client Component

// ❌ Don't do this — defeats the purpose of RSC
"use client";

export default function BlogPost({ post }) {
  return <div>{post.title}</div>; // No interactivity needed!
}

2. Passing Non-Serializable Props

// ❌ Can't pass functions from Server → Client
<ClientComponent onClick={() => console.log("hi")} />

// ✅ Use Server Actions instead
<ClientComponent action={serverAction} />

3. Importing Client Libraries in Server Components

// ❌ This will error
import { motion } from "framer-motion"; // Client-only library

export default function Page() {
  return <motion.div>...</motion.div>;
}

// ✅ Wrap in a client component
// animation-wrapper.tsx
"use client";
import { motion } from "framer-motion";
export function AnimatedDiv({ children }) {
  return <motion.div>{children}</motion.div>;
}

The Mental Model

Think of your component tree as a river:

                    Server (static, fast)
─────────────────────────────────────────────
  "use client"      ← waterline
─────────────────────────────────────────────
                    Client (interactive, JS)

When NOT to Use RSC

RSC isn’t the answer to everything:


Conclusion

RSC is the biggest shift in React since hooks. The key takeaways:

  1. Default to Server Components — only add "use client" when you need interactivity
  2. Use Suspense boundaries to enable streaming
  3. Fetch data in parallel with Promise.all
  4. Keep client components small and push them to the leaves of your component tree
  5. Measure before and after — benchmark your specific use case

Next up: Building a full-stack app with RSC, Server Actions, and edge deployment.

← Back to Blog

Subscribe to the Newsletter

Get notified when I publish new posts. No spam, unsubscribe anytime.