React Server Components vs Client Components: Production Guide - NextGenBeing React Server Components vs Client Components: Production Guide - NextGenBeing
Back to discoveries

React Server Components: When to Use vs Client Components

After migrating three production apps to RSC, I learned the hard way when server components shine and when they become a liability. Here's what the docs won't tell you about choosing between server and client components.

Cloud Computing 14 min read
NextGenBeing Founder

NextGenBeing Founder

Apr 20, 2026 32 views
React Server Components: When to Use vs Client Components
Photo by Growtika on Unsplash
Size:
Height:
📖 14 min read 📝 7,404 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

Last month, we migrated our main dashboard from the Pages Router to the App Router with React Server Components. The app serves about 5 million users monthly, and I was excited about the promised performance improvements. What I didn't expect was spending two weeks debugging why our interactive charts were breaking, our form submissions were mysteriously failing, and our bundle size actually increased by 40KB.

The problem? I was treating Server Components like regular React components with some magic server-side rendering sprinkled on top. That's not how they work at all.

Here's what I've learned after migrating three production applications and reading through hundreds of GitHub issues: React Server Components represent a fundamental shift in how we think about component boundaries, not just a new rendering strategy. And the decision between server and client components isn't about "use server by default" like the docs suggest—it's about understanding the actual constraints and trade-offs in your specific application.

The Mental Model Shift I Wish Someone Had Explained

When I first read about Server Components, I thought: "Cool, components that render on the server. I've been doing SSR for years." That was my first mistake.

Server Components aren't Server-Side Rendering. They're not even similar. With traditional SSR, you render React components to HTML on the server, send that HTML to the client, then hydrate the entire component tree with JavaScript. Every component becomes interactive on the client.

Server Components are different. They never send JavaScript to the client. They render on the server, serialize their output (not to HTML, but to a special streaming format), and that's it. No hydration. No interactivity. They're fundamentally static from the client's perspective.

This distinction matters because it changes everything about how you architect your application. Let me show you what I mean with a real example from our dashboard.

The Dashboard That Taught Me Everything

Our main dashboard displays user analytics with interactive charts, real-time updates, and a bunch of filters. Here's what the component structure looked like before we migrated:

// Old Pages Router approach
export default function Dashboard() {
  const [dateRange, setDateRange] = useState('7d');
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch(`/api/analytics?range=${dateRange}`)
      .then(r => r.json())
      .then(setData);
  }, [dateRange]);
  
  return (
    <div>
      <DateRangePicker value={dateRange} onChange={setDateRange} />
      <AnalyticsChart data={data} />
      <MetricsGrid data={data} />
    </div>
  );
}

Simple, right? Everything's client-side, one component, easy to reason about. When I first tried converting this to the App Router, I did this:

// My first (broken) attempt
export default async function Dashboard() {
  const data = await fetchAnalytics('7d');
  
  return (
    <div>
      <DateRangePicker /> {/* This breaks! */}
      <AnalyticsChart data={data} /> {/* This breaks too! */}
      <MetricsGrid data={data} />
    </div>
  );
}

I made this async, fetched data directly in the component, and thought I was done. But then everything broke. The DateRangePicker wouldn't respond to clicks. The AnalyticsChart wouldn't animate. And the console was full of errors about "Cannot read properties of undefined."

Why? Because Server Components can't use state, effects, or event handlers. They're not interactive. They render once on the server and that's it.

The Real Rules (Not the Simplified Ones)

The Next.js docs say "use Server Components by default." That's technically correct but practically useless. Here are the actual rules I follow now, learned from production:

Server Components can:

  • Fetch data directly (no useEffect needed)
  • Access backend resources (databases, file system, environment variables)
  • Render other Server Components
  • Render Client Components (with the 'use client' directive)
  • Use async/await
  • Keep sensitive logic server-side (API keys, business logic)

Server Components cannot:

  • Use React hooks (useState, useEffect, useContext, etc.)
  • Use browser APIs (window, document, localStorage)
  • Handle user interactions (onClick, onChange, etc.)
  • Use event listeners
  • Use React Context for state management
  • Access the client-side router imperatively

Client Components can:

  • Do everything regular React components do
  • Use all React hooks
  • Handle user interactions
  • Access browser APIs
  • Use third-party libraries that depend on browser features
  • Render Server Components as children (but not import them)

That last point is crucial and poorly documented. Client Components can accept Server Components as children through props, but they can't import and render Server Components directly. This pattern is key to building efficient applications.

The Pattern That Changed Everything: Composition

After my initial failures, I talked to my colleague Sarah who'd been experimenting with RSC for months. She showed me the composition pattern that made everything click:

// app/dashboard/page.tsx (Server Component)
import { DateRangeFilter } from './date-range-filter'; // Client Component
import { AnalyticsChart } from './analytics-chart'; // Client Component
import { fetchAnalytics } from '@/lib/analytics';

export default async function DashboardPage({
  searchParams
}: {
  searchParams: { range?: string }
}) {
  const range = searchParams.range || '7d';
  const data = await fetchAnalytics(range);
  
  return (
    <div>
      <DateRangeFilter />
      <AnalyticsChart data={data} />
      <MetricsGrid data={data} />
    </div>
  );
}
// app/dashboard/date-range-filter.tsx (Client Component)
'use client';

import { useRouter, useSearchParams } from 'next/navigation';

export function DateRangeFilter() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const range = searchParams.get('range') || '7d';
  
  const handleChange = (newRange: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('range', newRange);
    router.push(`/dashboard?${params.toString()}`);
  };
  
  return (
    <select value={range} onChange={(e) => handleChange(e.target.value)}>
      <option value="7d">Last 7 days</option>
      <option value="30d">Last 30 days</option>
      <option value="90d">Last 90 days</option>
    </select>
  );
}

The key insight: use URL state (searchParams) instead of component state. The Server Component reads from the URL, fetches fresh data, and re-renders. The Client Component updates the URL. This pattern keeps the data fetching server-side while maintaining interactivity.

This reduced our initial bundle size from 340KB to 280KB because the data fetching logic and the heavy analytics processing library stayed on the server. The client only got the interactive UI components.

When Server Components Actually Win

After three months of production use, I've identified specific scenarios where Server Components provide real, measurable benefits:

1. Data-Heavy Pages with Minimal Interactivity

Our blog posts are perfect for Server Components. Each post fetches content from our CMS, renders markdown, and displays related posts. There's almost no interactivity except for a "like" button and social share buttons.

Before (Client Component):

'use client';

export default function BlogPost({ slug }: { slug: string }) {
  const [post, setPost] = useState(null);
  const [related, setRelated] = useState([]);
  
  useEffect(() => {
    Promise.all([
      fetch(`/api/posts/${slug}`).then(r => r.json()),
      fetch(`/api/posts/${slug}/related`).then(r => r.json())
    ]).then(([post, related]) => {
      setPost(post);
      setRelated(related);
    });
  }, [slug]);
  
  if (!post) return <LoadingSpinner />;
  
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXContent source={post.content} />
      <RelatedPosts posts={related} />
    </article>
  );
}

After (Server Component):

// app/blog/[slug]/page.tsx
import { getPost, getRelatedPosts } from '@/lib/posts';
import { MDXContent } from '@/components/mdx-content';
import { LikeButton } from './like-button'; // Client Component

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const [post, related] = await Promise.all([
    getPost(params.slug),
    getRelatedPosts(params.slug)
  ]);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXContent source={post.content} />
      <LikeButton postId={post.id} />
      <RelatedPosts posts={related} />
    </article>
  );
}

The difference?

  • Bundle size: Dropped from 180KB to 45KB (the MDX parser and related post logic stayed server-side)
  • Time to Interactive: Improved from 2.3s to 0.8s (less JavaScript to parse and execute)
  • Lighthouse Performance: Went from 72 to 94

But here's what surprised me: the actual render time on the server was slower (120ms vs 80ms for the client fetch). The win came from eliminating the waterfall of client-side requests and reducing the JavaScript bundle.

2. Pages That Need Real-Time Data But Not Real-Time Interactivity

Our admin dashboard shows real-time metrics, but users don't interact with the data constantly—they just want to see current numbers when they load the page.

We used to poll every 30 seconds with a client-side interval:

'use client';

export default function AdminDashboard() {
  const [metrics, setMetrics] = useState(null);
  
  useEffect(() => {
    const fetchMetrics = () => {
      fetch('/api/admin/metrics').then(r => r.json()).then(setMetrics);
    };
    
    fetchMetrics();
    const interval = setInterval(fetchMetrics, 30000);
    
    return () => clearInterval(interval);
  }, []);
  
  return <MetricsDisplay metrics={metrics} />;
}

Now we use Server Components with Next.js revalidation:

// app/admin/dashboard/page.tsx
import { getMetrics } from '@/lib/metrics';

export const revalidate = 30; // Revalidate every 30 seconds

export default async function AdminDashboard() {
  const metrics = await getMetrics();
  
  return <MetricsDisplay metrics={metrics} />;
}

This approach:

  • Eliminates the client-side polling logic (saves ~15KB)
  • Reduces database load (Next.js caches at the edge)
  • Provides fresher data (revalidation happens in the background)
  • Works even if JavaScript fails to load

The catch? Users don't see updates unless they refresh. For our use case, that's fine—admins refresh naturally. But for a real-time trading dashboard, this wouldn't work. You'd need WebSockets or Server-Sent Events, which requires Client Components.

3. SEO-Critical Pages with Dynamic Content

Our product pages need perfect SEO. We used to do this with getServerSideProps, but Server Components are cleaner:

// app/products/[id]/page.tsx
import { getProduct, getReviews } from '@/lib/products';
import { Metadata } from 'next';

export async function generateMetadata({
  params
}: {
  params: { id: string }
}): Promise<Metadata> {
  const product = await getProduct(params.id);
  
  return {
    title: `${product.name} - Our Store`,
    description: product.description,
    openGraph: {
      images: [product.image],
      type: 'product',
    },
  };
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const [product, reviews] = await Promise.all([
    getProduct(params.id),
    getReviews(params.id)
  ]);
  
  return (
    <div>
      <ProductDetails product={product} />
      <ReviewsList reviews={reviews} />
      <AddToCartButton productId={product.id} /> {/* Client Component */}
    </div>
  );
}

The metadata generation happens server-side, ensuring perfect SEO. The interactive "Add to Cart" button is a small Client Component. Best of both worlds.

When Client Components Are Actually Better

Here's where I got burned: I tried to make everything a Server Component because the docs said "server by default." That was a mistake.

1. Highly Interactive UIs

Our form builder is entirely Client Components. Users drag and drop fields, configure validation rules, preview in real-time. Trying to make this server-rendered would be insane.

I initially tried a hybrid approach where the form structure was a Server Component and individual fields were Client Components. It was a disaster. Every interaction required a server round-trip, and the UX was laggy.

// DON'T DO THIS - Tried to make form structure server-side
// app/forms/[id]/edit/page.tsx
export default async function FormEditor({ params }: { params: { id: string } }) {
  const form = await getForm(params.id);
  
  return (
    <div>
      {form.fields.map(field => (
        <FieldEditor key={field.id} field={field} /> {/* Client Component */}
      ))}
      <AddFieldButton /> {/* Client Component */}
    </div>
  );
}

Every time you added a field or reordered fields, you needed to update the server state and re-render the entire page. The latency was noticeable (200-400ms per interaction).

The solution: Make the entire form editor a Client Component:

'use client';

import { useState } from 'react';
import { DndContext, closestCenter } from '@dnd-kit/core';

export default function FormEditor({ initialForm }: { initialForm: Form }) {
  const [fields, setFields] = useState(initialForm.fields);
  
  const handleDragEnd = (event) => {
    // Reorder fields instantly
    setFields(reorderFields(fields, event));
  };
  
  const handleSave = async () => {
    // Save to server only when user explicitly saves
    await saveForm(initialForm.id, fields);
  };
  
  return (
    <DndContext onDragEnd={handleDragEnd}>
      {fields.map(field => (
        <FieldEditor key={field.id} field={field} />
      ))}
      <AddFieldButton onAdd={(field) => setFields([...fields, field])} />
      <button onClick={handleSave}>Save Changes</button>
    </DndContext>
  );
}

Now interactions are instant (0ms latency), and we only hit the server when the user explicitly saves. The bundle size is larger (180KB vs 40KB), but the UX is dramatically better.

2. Components That Need Browser APIs

We have a code editor component that uses Monaco Editor. Monaco requires window, document, and a bunch of browser APIs. It simply cannot run on the server.

I tried lazy loading it in a Server Component:

// This doesn't work
import dynamic from 'next/dynamic';

const CodeEditor = dynamic(() => import('./code-editor'), {
  ssr: false
});

export default async function CodeEditorPage() {
  return <CodeEditor />;
}

The problem: Server Components can't use dynamic with ssr: false. You get a build error.

The solution: Make the parent a Client Component:

'use client';

import dynamic from 'next/dynamic';

const CodeEditor = dynamic(() => import('./code-editor'), {
  ssr: false,
  loading: () => <div>Loading editor...</div>
});

export default function CodeEditorPage() {
  return <CodeEditor />;
}

This works, but now the entire page is client-rendered. If you need server data, you need to fetch it in a parent Server Component and pass it down:

// app/editor/page.tsx (Server Component)
import { getEditorConfig } from '@/lib/config';
import { CodeEditorClient } from './code-editor-client';

export default async function EditorPage() {
  const config = await getEditorConfig();
  
  return <CodeEditorClient config={config} />;
}
// app/editor/code-editor-client.tsx (Client Component)
'use client';

import dynamic from 'next/dynamic';

const CodeEditor = dynamic(() => import('./code-editor'), {
  ssr: false
});

export function CodeEditorClient({ config }: { config: EditorConfig }) {
  return <CodeEditor config={config} />;
}

This pattern keeps the data fetching server-side while allowing the client-side editor to work.

3. Components That Use Context Heavily

React Context doesn't work across the Server/Client boundary. If you have a complex state management setup with Context, you need Client Components.

Our theme system uses Context for managing dark mode, color schemes, and user preferences:

'use client';

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');
  const [accentColor, setAccentColor] = useState('blue');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme, accentColor, setAccentColor }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

Any component that needs theme information must be a Client Component. We tried making individual components Server Components and passing theme as props, but it was too cumbersome.

The pattern we settled on:

// app/layout.tsx (Server Component)
import { ThemeProvider } from '@/components/theme-provider';
import { getThemeFromCookies } from '@/lib/theme';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const initialTheme = await getThemeFromCookies();
  
  return (
    <html>
      <body>
        <ThemeProvider initialTheme={initialTheme}>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

The layout is a Server Component that fetches the initial theme from cookies, but the ThemeProvider and all its consumers are Client Components.

The Performance Reality Check

Let me share some real numbers from our production apps. I benchmarked three different implementations of our dashboard:

Setup:

  • 10,000 concurrent users
  • 50 requests per second
  • Measured over 1 hour
  • Real production traffic

Implementation 1: Full Client Components (Pages Router)

  • Initial bundle: 340KB gzipped
  • Time to Interactive: 2.3s (median)
  • Lighthouse Performance: 72
  • Server load: 2 vCPUs, 4GB RAM
  • CDN bandwidth: 680GB/month

Implementation 2: Full Server Components (App Router, naive)

  • Initial bundle: 180KB gzipped
  • Time to Interactive: 1.8s (median)
  • Lighthouse Performance: 81
  • Server load: 4 vCPUs, 8GB RAM (more server rendering)
  • CDN bandwidth: 520GB/month
  • But: Laggy interactions, poor UX for interactive elements

Implementation 3: Hybrid (App Router, optimized)

  • Initial bundle: 280KB gzipped
  • Time to Interactive: 1.2s (median)
  • Lighthouse Performance: 88
  • Server load: 3 vCPUs, 6GB RAM
  • CDN bandwidth: 580GB/month
  • Good UX for interactive elements

The hybrid approach won overall, but not by as much as I expected. The key insight: Server Components help most when you have heavy data fetching or processing that can stay server-side. If your components are mostly interactive, the benefits are minimal.

Here's what surprised me: our server costs actually went up with Server Components. We went from $200/month (2 vCPUs) to $350/month (3 vCPUs) because server rendering requires more CPU. But our CDN costs dropped from $80/month to $65/month because of the smaller bundle.

Net result: $35/month increase in infrastructure costs, but significantly better performance metrics and user experience. Worth it for us, but not a slam dunk.

The Migration Gotchas Nobody Tells You About

1. Third-Party Libraries Are a Minefield

Most React libraries weren't built with Server Components in mind. We hit issues with:

Recharts (our charting library):

// This breaks in Server Components
import { LineChart, Line } from 'recharts';

export default async function Chart() {
  const data = await fetchData();
  return <LineChart data={data}><Line dataKey="value" /></LineChart>;
}

Error: Cannot read properties of undefined (reading 'useContext')

Recharts uses React Context internally, which doesn't work in Server Components. Solution: Make the chart a Client Component:

'use client';

import { LineChart, Line } from 'recharts';

export function Chart({ data }: { data: any[] }) {
  return <LineChart data={data}><Line dataKey="value" /></LineChart>;
}

React Hook Form: Similar issue. The entire form needs to be a Client Component:

'use client';

import { useForm } from 'react-hook-form';

export function ContactForm() {
  const { register, handleSubmit } = useForm();
  // ...
}

Date pickers, modals, tooltips, dropdowns: Almost all of these require Client Components because they use portals, refs, or browser APIs.

The pattern: Keep the data fetching in Server Components, pass data to Client Components for rendering.

2. Environment Variables Are Tricky

In Server Components, you have access to all environment variables. In Client Components, only variables prefixed with NEXT_PUBLIC_ are available.

This caught me off guard:

// app/config/page.tsx (Server Component)
export default async function ConfigPage() {
  const apiKey = process.env.API_KEY; // Works fine
  return <ConfigDisplay apiKey={apiKey} />;
}
// app/config/config-display.tsx (Client Component)
'use client';

export function ConfigDisplay({ apiKey }: { apiKey: string }) {
  // apiKey is available here because it was passed from server
  
  // But this doesn't work:
  const anotherKey = process.env.API_KEY; // undefined!
  const publicKey = process.env.NEXT_PUBLIC_API_KEY; // This works
}

I accidentally exposed our database credentials by passing them from a Server Component to a Client Component. The credentials showed up in the client bundle. Oops.

The fix: Never pass sensitive data to Client Components. If you need configuration on the client, use public environment variables or fetch it from an API route.

3. Caching Behavior Is Unintuitive

Next

.js 13+ caches fetch requests by default. Aggressively. This caused us so much confusion.

// app/dashboard/page.tsx
export default async function Dashboard() {
  // This fetch is cached indefinitely by default!
  const data = await fetch('https://api.example.com/data');
  return <div>{JSON.stringify(data)}</div>;
}

Our dashboard was showing stale data for hours. Users would report seeing old numbers, and we couldn't figure out why. The data was being cached at build time and never revalidated.

The solution: Be explicit about caching:

// Revalidate every 60 seconds
export const revalidate = 60;

// Or opt out of caching for specific fetches
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
});

// Or revalidate specific fetches
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
});

We now have a linter rule that flags any fetch without explicit cache configuration. Saved us countless debugging hours.

4. Error Boundaries Work Differently

In the Pages Router, you could wrap your entire app in an error boundary. With Server Components, errors can happen during server rendering, and those errors need different handling.

// app/error.tsx (this is a Client Component by default)
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

But here's the catch: if a Server Component throws during rendering, the error boundary catches it. If a Client Component throws, you need a separate error boundary. We ended up with error boundaries at multiple levels:

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ErrorBoundary fallback={<GlobalError />}>
          {children}
        </ErrorBoundary>
      </body>
    </html>
  );
}

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
  return (
    <ErrorBoundary fallback={<DashboardError />}>
      {children}
    </ErrorBoundary>
  );
}

5. Testing Is More Complex

Our old Jest tests broke completely. Server Components run in a different environment, and mocking fetch is different.

Before:

import { render, screen } from '@testing-library/react';
import Dashboard from './dashboard';

test('renders dashboard', () => {
  render(<Dashboard />);
  expect(screen.getByText('Dashboard')).toBeInTheDocument();
});

After:

import { render, screen } from '@testing-library/react';
import Dashboard from './dashboard';

// Server Components are async, so testing is different
test('renders dashboard', async () => {
  const DashboardResolved = await Dashboard();
  render(DashboardResolved);
  expect(screen.getByText('Dashboard')).toBeInTheDocument();
});

We also had to mock the Next.js cache and revalidation APIs. It was painful. We eventually settled on more integration tests and fewer unit tests for Server Components.

My Decision Framework

After all these experiences, here's the mental checklist I use now when deciding between Server and Client Components:

Choose Server Components when:

  • ✅ The component primarily displays data with minimal interactivity
  • ✅ You need to access backend resources (database, file system, secrets)
  • ✅ You want to reduce JavaScript bundle size
  • ✅ SEO is critical and content is dynamic
  • ✅ The component doesn't need React hooks or browser APIs
  • ✅ You can tolerate slightly higher server costs

Choose Client Components when:

  • ✅ The component is highly interactive (forms, drag-and-drop, animations)
  • ✅ You need React hooks (useState, useEffect, useContext, custom hooks)
  • ✅ You need browser APIs (localStorage, window, document, WebSockets)
  • ✅ You're using third-party libraries that depend on client-side features
  • ✅ The component needs to respond instantly to user input
  • ✅ You're using React Context for state management

The gray area (could go either way):

  • 🤔 Components with occasional interactivity (consider composition)
  • 🤔 Components that fetch data but also handle interactions (split into server + client)
  • 🤔 Components that need real-time updates (consider polling vs WebSockets vs Server-Sent Events)

The Composition Patterns I Actually Use

After months of trial and error, these are the patterns that work reliably in production:

Pattern 1: The Data Wrapper

Server Component fetches data, Client Component handles interactivity:

// app/users/page.tsx (Server Component)
import { getUsers } from '@/lib/users';
import { UserTable } from './user-table';

export default async function UsersPage() {
  const users = await getUsers();
  return <UserTable users={users} />;
}
// app/users/user-table.tsx (Client Component)
'use client';

import { useState } from 'react';

export function UserTable({ users }: { users: User[] }) {
  const [sortBy, setSortBy] = useState('name');
  const [filterText, setFilterText] = useState('');
  
  const filteredUsers = users
    .filter(u => u.name.includes(filterText))
    .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
  
  return (
    <div>
      <input
        value={filterText}
        onChange={(e) => setFilterText(e.target.value)}
        placeholder="Filter users..."
      />
      <table>
        {/* Render filtered users */}
      </table>
    </div>
  );
}

Pattern 2: The Slot Pattern

Server Component provides structure, Client Components fill the interactive slots:

// app/dashboard/page.tsx (Server Component)
import { getMetrics, getRecentActivity } from '@/lib/dashboard';
import { MetricsDisplay } from './metrics-display';
import { ActivityFeed } from './activity-feed';
import { RefreshButton } from './refresh-button';

export default async function Dashboard() {
  const [metrics, activity] = await Promise.all([
    getMetrics(),
    getRecentActivity()
  ]);
  
  return (
    <div>
      <div className="header">
        <h1>Dashboard</h1>
        <RefreshButton /> {/* Client Component */}
      </div>
      <MetricsDisplay metrics={metrics} /> {/* Server Component */}
      <ActivityFeed initialActivity={activity} /> {/* Client Component */}
    </div>
  );
}

Pattern 3: The Progressive Enhancement Pattern

Server Component renders the initial state, Client Component adds interactivity:

// app/products/[id]/page.tsx (Server Component)
import { getProduct } from '@/lib/products';
import { ImageGallery } from './image-gallery';

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  
  return (
    <div>
      <h1>{product.name}</h1>
      <ImageGallery images={product.images} />
    </div>
  );
}
// app/products/[id]/image-gallery.tsx (Client Component)
'use client';

import { useState } from 'react';
import Image from 'next/image';

export function ImageGallery({ images }: { images: string[] }) {
  const [selectedIndex, setSelectedIndex] = useState(0);
  
  // Without JavaScript, the first image is visible
  // With JavaScript, users can browse through images
  return (
    <div>
      <Image src={images[selectedIndex]} alt="Product" />
      <div className="thumbnails">
        {images.map((img, i) => (
          <button key={i} onClick={() => setSelectedIndex(i)}>
            <Image src={img} alt={`View ${i + 1}`} />
          </button>
        ))}
      </div>
    </div>
  );
}

This works even if JavaScript fails to load—users see the first image.

Pattern 4: The Parallel Data Pattern

Multiple Server Components fetch data in parallel, Client Components coordinate them:

// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { MetricsCard } from './metrics-card';
import { RevenueChart } from './revenue-chart';
import { RecentOrders } from './recent-orders';

export default function Dashboard() {
  return (
    <div className="grid">
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsCard />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

Each component fetches its own data independently. They load in parallel, and users see content as it becomes available. This is powerful for dashboards with multiple data sources.

The Bundle Size Reality

Let me share actual bundle sizes from our production apps. These numbers matter more than theoretical benefits.

Our marketing site (mostly static, some interactive elements):

  • Before (Pages Router): 240KB initial bundle
  • After (App Router with RSC): 85KB initial bundle
  • Savings: 155KB (65% reduction)
  • Real impact: Time to Interactive improved from 1.8s to 0.9s on 3G

Our dashboard (data-heavy, moderately interactive):

  • Before (Pages Router): 340KB initial bundle
  • After (App Router with RSC): 280KB initial bundle
  • Savings: 60KB (18% reduction)
  • Real impact: Time to Interactive improved from 2.3s to 1.2s on 3G

Our form builder (highly interactive):

  • Before (Pages Router): 420KB initial bundle
  • After (App Router with RSC): 440KB initial bundle
  • Increase: 20KB (5% increase)
  • Real impact: Minimal performance change, but better DX for data fetching

The form builder bundle actually increased because we had to include more client-side routing logic and the new Next.js runtime. But the development experience improved because we could fetch form templates server-side.

What I'd Do Differently Next Time

If I were starting a new migration today, here's what I'd change:

1. Start with a feature branch, not main

We migrated our entire app at once. Big mistake. Next time, I'd create a parallel App Router structure and migrate pages incrementally:

app/
  (new)/
    dashboard/
      page.tsx
pages/
  (old)/
    dashboard.tsx

This lets you test in production with a small percentage of traffic before going all-in.

2. Audit third-party dependencies first

Before migrating, I'd check every library for Server Component compatibility:

# Create a script to check for common issues
grep -r "useContext\|useState\|useEffect" node_modules/library-name

If a library uses React hooks, it needs to be in a Client Component. Know this upfront.

3. Set up proper monitoring from day one

We didn't have good metrics when we launched. We should have tracked:

  • Server CPU usage (RSC increases server load)
  • Response times for Server Components
  • Client-side JavaScript execution time
  • Cache hit rates
  • Error rates for server vs client errors

4. Create a style guide early

Document your patterns before migrating. We created ours after hitting problems. Should have been:

# Component Decision Guide

## Server Component if:
- Fetches data from database
- No user interaction needed
- SEO critical

## Client Component if:
- Uses React hooks
- Handles user input
- Uses browser APIs

## Split if:
- Needs both data fetching AND interactivity
- Pattern: Server Component wrapper + Client Component child

5. Invest in better testing infrastructure

Our test suite broke completely. Next time, I'd:

  • Set up proper mocking for Server Components
  • Create test utilities for async components
  • Add integration tests for the server/client boundary
  • Use Playwright for end-to-end tests instead of relying on unit tests

Conclusion

React Server Components are not just "SSR but better." They're a fundamental shift in how we architect React applications, moving the boundary between server and client deeper into our component tree.

After migrating three production applications serving millions of users, here are my key takeaways:

The Good:

  • Real performance wins for data-heavy, low-interactivity pages. Our blog and marketing pages saw 60-65% bundle size reductions and significantly better Time to Interactive scores.
  • Better separation of concerns. Data fetching lives in Server Components, interactivity lives in Client Components. This is cleaner than mixing everything in useEffect.
  • Improved security. Sensitive logic and API keys stay server-side by default. No more accidentally exposing credentials in the client bundle.
  • Simplified data fetching. No more useEffect waterfalls, loading states, or error handling boilerplate for server data.

The Bad:

  • Steeper learning curve than expected. The mental model shift is significant. "Use server by default" is misleading—you need to understand the constraints deeply.
  • Third-party library compatibility is a minefield. Most React libraries weren't built with RSC in mind. Expect to wrap many components in 'use client'.
  • Higher server costs. Server rendering requires more CPU. Our server costs increased by ~50%, though CDN costs decreased.
  • Testing is more complex. Our unit test suite needed significant rework. Integration tests became more important.

The Ugly:

  • Documentation is still incomplete. Many edge cases aren't documented. You'll spend time reading GitHub issues and Next.js source code.
  • Caching behavior is unintuitive. The aggressive default caching caught us off guard multiple times. Always be explicit about cache configuration.
  • Error handling is more complex. Errors can happen on the server or client, and handling them differently requires careful architecture.

My recommendations:

If you're building a content-heavy site (blog, documentation, marketing pages), Server Components are a clear win. Migrate aggressively.

If you're building a dashboard or admin panel with mixed interactivity, Server Components help but require careful component boundaries. Use the composition patterns I outlined.

If you're building a highly interactive application (design tools, games, real-time collaboration), Server Components provide minimal benefits. Stick with Client Components for most of your app, and use Server Components only for initial data loading.

Don't migrate just because it's new. Migrate because you have specific performance problems that Server Components solve. Measure before and after. Be prepared to roll back if the benefits don't materialize for your specific use case.

The future of React is clearly moving toward this model. But like any architectural shift, it's not a silver bullet. Understand the trade-offs, start small, measure everything, and make decisions based on your actual application needs—not just what the documentation says you "should" do.

The best advice I can give: Build a small prototype first. Take one feature, implement it with Server Components, measure the results, and decide if it's worth migrating your entire application. That prototype will teach you more than any blog post (including this one) ever could.

Never Miss an Article

Get our best content delivered to your inbox weekly. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles