React Server Components: Rethinking Where Your Code Runs

Photo of post's author

Lavacoder

Frontend Engineer

Jan 11, 2026
5 min read
Logo of the React library
Share This Article

React Server Components (RSC) isn't about making everything faster. It's about making deliberate choices about where your code executes and what gets sent to the browser. After migrating three production apps to the App Router and RSC, I can tell you it's less "paradigm shift" and more "additional option that sometimes matters a lot."

What RSC Actually Solves (And What It Doesn't)

Let's start with what traditional React looks like. You write components, they get bundled into JavaScript, sent to the browser, executed, and then—if you need data—they make API calls back to your server:

// Traditional Client Component
'use client';
import { useState, useEffect } from 'react';
import { formatDate } from 'date-fns'; // 50KB
import { marked } from 'marked'; // 45KB

export default function BlogPost({ slug }) {
  const [post, setPost] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/posts/${slug}`)
      .then(res => res.json())
      .then(data => {
        setPost(data);
        setLoading(false);
      });
  }, [slug]);
  
  if (loading) return <div>Loading...</div>;
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{formatDate(post.date, 'MMMM d, yyyy')}</time>
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
    </article>
  );
}

What just happened? Your user downloaded:

  • React runtime (~130KB)
  • Your component code (~5KB)
  • date-fns (~50KB)
  • marked (~45KB)
  • Total: ~230KB of JavaScript

Then their browser executed all that code, made a network request, and finally showed the content. This is the "waterfall" everyone complains about.

Here's the Server Component version:

// Server Component (no 'use client' directive)
import { formatDate } from 'date-fns';
import { marked } from 'marked';
import { db } from '@/lib/db';

export default async function BlogPost({ slug }) {
  // This runs on the server, close to your database
  const post = await db.post.findUnique({ where: { slug } });
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{formatDate(post.date, 'MMMM d, yyyy')}</time>
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
    </article>
  );
}

What the user downloads now: ~0KB additional JavaScript. They get rendered HTML. No date-fns, no marked, no useEffect, no loading state. The libraries run on your server and stay there.

That's the core value proposition. Not that everything is faster, but that some things can stay on the server entirely.

When RSC Actually Helps

I migrated a product dashboard that was struggling with bundle size. The page displayed analytics data with charts, formatted dates, and markdown-rendered release notes. The client bundle was 680KB. Here's what we had:

// Before: Everything client-side
'use client';
import Chart from 'chart.js'; // 180KB
import { formatDistanceToNow } from 'date-fns'; // 50KB
import { unified } from 'unified'; // 120KB + plugins
import remarkParse from 'remark-parse';
import remarkHtml from 'remark-html';

export default function Dashboard() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('/api/analytics').then(/* ... */);
  }, []);
  
  // Heavy processing in the browser
  const formattedData = processAnalytics(data);
  const renderedNotes = renderMarkdown(notes);
  
  return (
    <div>
      <Chart data={formattedData} />
      <div dangerouslySetInnerHTML={{ __html: renderedNotes }} />
    </div>
  );
}

After splitting into Server and Client Components:

// app/dashboard/page.jsx - Server Component
import { db } from '@/lib/db';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkHtml from 'remark-html';
import AnalyticsChart from '@/components/AnalyticsChart';

export default async function DashboardPage() {
  // Database query on server
  const analytics = await db.analytics.findMany({
    where: { date: { gte: thirtyDaysAgo } }
  });
  
  // Heavy processing on server
  const processedData = processAnalytics(analytics);
  
  // Markdown rendering on server
  const notes = await db.releaseNotes.findMany({ take: 5 });
  const renderedNotes = await Promise.all(
    notes.map(note => 
      unified()
        .use(remarkParse)
        .use(remarkHtml)
        .process(note.content)
    )
  );
  
  return (
    <div>
      {/* Client Component gets pre-processed data */}
      <AnalyticsChart data={processedData} />
      
      {/* Server-rendered HTML */}
      <div>
        {renderedNotes.map((note, i) => (
          <div key={i} dangerouslySetInnerHTML={{ __html: note.value }} />
        ))}
      </div>
    </div>
  );
}

// components/AnalyticsChart.jsx - Client Component
'use client';
import Chart from 'chart.js';

export default function AnalyticsChart({ data }) {
  // Chart.js still runs client-side because it needs DOM interaction
  return <Chart data={data} />;
}

New client bundle: 185KB (just Chart.js). The markdown and date processing libraries stayed on the server. First Contentful Paint improved by 1.2 seconds on 3G connections.

The Composition Pattern That Actually Works

The biggest mistake I made early on was this:

// ❌ This makes EVERYTHING client-side
'use client';
import { useState } from 'react';

export default function DashboardPage() {
  const [filter, setFilter] = useState('week');
  
  return (
    <div>
      <FilterButtons value={filter} onChange={setFilter} />
      <AnalyticsData filter={filter} />
      <RecentActivity filter={filter} />
      <TeamMembers />
      <DocumentationLinks />
    </div>
  );
}

By putting 'use client' at the top, every child component—even the static ones—becomes client-side. The entire page is now in your JavaScript bundle.

The right approach is component composition:

// ✅ Server Component (default)
import { db } from '@/lib/db';
import FilterableAnalytics from '@/components/FilterableAnalytics';
import TeamMembers from '@/components/TeamMembers';
import DocLinks from '@/components/DocLinks';

export default async function DashboardPage() {
  // Fetch data on server
  const [analytics, team, docs] = await Promise.all([
    db.analytics.findMany(),
    db.teamMembers.findMany(),
    db.docs.findMany()
  ]);
  
  return (
    <div>
      {/* Only this component is client-side */}
      <FilterableAnalytics initialData={analytics} />
      
      {/* These stay server-rendered */}
      <TeamMembers members={team} />
      <DocLinks docs={docs} />
    </div>
  );
}

// components/FilterableAnalytics.jsx
'use client';
import { useState } from 'react';

export default function FilterableAnalytics({ initialData }) {
  const [filter, setFilter] = useState('week');
  
  const filteredData = initialData.filter(/* ... */);
  
  return (
    <div>
      <select value={filter} onChange={e => setFilter(e.target.value)}>
        <option value="week">This Week</option>
        <option value="month">This Month</option>
      </select>
      <AnalyticsChart data={filteredData} />
    </div>
  );
}

Now only the interactive parts are client-side. The static content stays server-rendered.

Data Fetching: The Mental Model Shift

In traditional React, you'd fetch data in useEffect or with a data fetching library:

// Old pattern
'use client';
export default function ProductPage({ id }) {
  const { data, isLoading } = useQuery(['product', id], () =>
    fetch(`/api/products/${id}`).then(r => r.json())
  );
  
  if (isLoading) return <Spinner />;
  return <Product data={data} />;
}

With Server Components, you just... fetch the data:

// New pattern
import { db } from '@/lib/db';

export default async function ProductPage({ params }) {
  const product = await db.product.findUnique({
    where: { id: params.id }
  });
  
  return <Product data={product} />;
}

No loading states, no useEffect, no client-side data fetching library. The component suspends automatically while data loads. If you need a loading UI, you use Suspense:

import { Suspense } from 'react';

export default function ProductPage({ params }) {
  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductDetails id={params.id} />
    </Suspense>
  );
}

async function ProductDetails({ id }) {
  const product = await db.product.findUnique({ where: { id } });
  return <Product data={product} />;
}

This took me a while to internalize. You're not "fetching" data anymore—you're just accessing it. The component renders when the data is ready.

Parallel Data Fetching: The Gotcha

Here's a mistake I made that killed performance:

// ❌ Sequential fetching - SLOW
export default async function Dashboard() {
  const user = await db.user.findUnique({ where: { id: userId } });
  const posts = await db.post.findMany({ where: { authorId: user.id } });
  const comments = await db.comment.findMany({ where: { authorId: user.id } });
  
  return <DashboardUI user={user} posts={posts} comments={comments} />;
}

Each await blocks the next one. Total time: user query + posts query + comments query.

The fix is Promise.all:

// ✅ Parallel fetching - FAST
export default async function Dashboard() {
  const [user, posts, comments] = await Promise.all([
    db.user.findUnique({ where: { id: userId } }),
    db.post.findMany({ where: { authorId: userId } }),
    db.comment.findMany({ where: { authorId: userId } })
  ]);
  
  return <DashboardUI user={user} posts={posts} comments={comments} />;
}

Now all three queries run simultaneously. Total time: slowest query.

But there's an even better pattern with Suspense boundaries:

export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>
      
      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts />
      </Suspense>
      
      <Suspense fallback={<CommentsSkeleton />}>
        <UserComments />
      </Suspense>
    </div>
  );
}

async function UserProfile() {
  const user = await db.user.findUnique({ where: { id: userId } });
  return <UserCard user={user} />;
}

async function UserPosts() {
  const posts = await db.post.findMany({ where: { authorId: userId } });
  return <PostList posts={posts} />;
}

Now each section renders independently as its data becomes available. The user sees the fast stuff immediately and the slow stuff loads in progressively.

When NOT to Use Server Components

RSC isn't always the answer. Here's when I stick with Client Components:

Heavy user interaction:

// This needs to be client-side
'use client';
export default function InteractiveCanvas() {
  const canvasRef = useRef(null);
  
  useEffect(() => {
    const ctx = canvasRef.current.getContext('2d');
    // Complex canvas drawing logic
  }, []);
  
  return <canvas ref={canvasRef} />;
}

Browser APIs:

// Client Component - needs localStorage, geolocation, etc.
'use client';
export default function LocationPicker() {
  const [location, setLocation] = useState(null);
  
  useEffect(() => {
    navigator.geolocation.getCurrentPosition(pos => {
      setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude });
    });
  }, []);
  
  return <Map center={location} />;
}

Real-time updates:

// Client Component - WebSocket connection
'use client';
export default function LiveChat() {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const ws = new WebSocket('wss://...');
    ws.onmessage = (e) => {
      setMessages(prev => [...prev, JSON.parse(e.data)]);
    };
    return () => ws.close();
  }, []);
  
  return <ChatUI messages={messages} />;
}

The Real Performance Impact

On the dashboard migration, here are the actual numbers:

Before (Client-Side Rendering):

  • JavaScript bundle: 680KB (210KB gzipped)
  • Time to Interactive: 3.8s on 4G
  • Lighthouse Performance: 67

After (Server Components):

  • JavaScript bundle: 185KB (58KB gzipped)
  • Time to Interactive: 1.4s on 4G
  • Lighthouse Performance: 94

More importantly, the server components improved our 95th percentile load time on slow connections from 8.2s to 2.9s. That's the metric that actually matters for users on poor networks.

The Learning Curve Is Real

It took me about two weeks of building with RSC before the mental model clicked. The hardest parts were:

  • Unlearning useEffect for data fetching - I kept reaching for it out of habit
  • Understanding the serialization boundary - You can't pass functions or class instances from server to client components
  • Debugging - Error messages can be cryptic when you mix server and client code incorrectly

But once it clicks, you start seeing opportunities everywhere. That heavy library you're using for syntax highlighting? Server component. That markdown renderer? Server component. That complex data transformation? Server component.

The rule of thumb I've settled on: Start with Server Components by default, add 'use client' only when you need interactivity. This is the opposite of how we've been building React apps for the last decade, and that's precisely why it takes some rewiring.

Is It Worth The Migration?

For new projects? Absolutely. The default should be Server Components with Client Components sprinkled in where needed.

For existing apps? It depends. If you're already on Next.js and your bundle is large, the migration is worth it. If you're on Create React App or Vite with a small, highly interactive app, the ROI might not be there.

RSC isn't magic. It's a tool that solves specific problems—mainly bundle size and initial render performance. Use it where it makes sense, ignore it where it doesn't.