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 50,000 lines of production code to React Server Components, I learned the hard way which patterns work and which create maintenance nightmares. Here's what the docs don't tell you.

AI Workflows Premium Content 30 min read
Daniel Hartwell

Daniel Hartwell

May 22, 2026 0 views
Size:
Height:
📖 30 min read 📝 9,769 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

React Server Components: When to Use vs Client Components

Last year, our team at a mid-sized SaaS company made the decision to migrate our entire dashboard application—roughly 50,000 lines of React code—to the new Server Components architecture. We thought we understood the paradigm. We'd read the RFC, watched the Next.js 13 announcement videos, and felt confident. Six months later, after countless debugging sessions, performance regressions, and one particularly painful production incident, I can tell you: the reality of Server Components is far more nuanced than the marketing materials suggest.

The promise was compelling: faster initial page loads, reduced JavaScript bundle sizes, direct database access from components. What we got was all of that, plus a whole new category of bugs we'd never encountered before. Serialization errors at 2 AM. Hydration mismatches that only appeared in production. Components that worked perfectly in development but broke under load.

This isn't a criticism of Server Components—they're genuinely powerful when used correctly. But "correctly" is doing a lot of work in that sentence. After migrating our codebase and working through the pain points, I've developed strong opinions about when to reach for Server Components versus sticking with traditional Client Components. This article shares what I learned, including the mistakes we made, the patterns that saved us, and the decision framework I now use when architecting new features.

The Mental Model Shift Nobody Prepared Me For

Before diving into the technical details, let me address the biggest challenge we faced: Server Components require a fundamentally different way of thinking about React applications. For years, we've built React apps with a clear separation: the backend serves data via APIs, and the frontend fetches that data and renders UI. Server Components blur this boundary in ways that feel uncomfortable at first.

I remember the first time I wrote a Server Component that directly queried our PostgreSQL database. My colleague Sarah, our team lead, reviewed the PR and said, "This feels wrong." She was right to be suspicious. We'd spent years enforcing API boundaries, building proper REST endpoints, and maintaining separation of concerns. Now we were writing SQL queries directly in our component files?

The shift requires accepting that Server Components are backend code that happens to output React elements. They run on the server, have access to server resources, and never execute in the browser. This isn't just a technical detail—it changes how you architect your entire application.

Here's a concrete example that illustrates the paradigm shift. In our old architecture, a dashboard component looked like this:

// Client Component (old approach)
'use client';

import { useState, useEffect } from 'react';

export default function Dashboard() {
  const [metrics, setMetrics] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/metrics')
      .then(res => res.json())
      .then(data => {
        setMetrics(data);
        setLoading(false);
      });
  }, []);

  if (loading) return ;
  return ;
}

With Server Components, the same functionality becomes:

// Server Component (new approach)
import { db } from '@/lib/database';

export default async function Dashboard() {
  const metrics = await db.query('SELECT * FROM metrics WHERE user_id = $1', [userId]);
  return ;
}

The second version is objectively simpler—no loading states, no useEffect, no API endpoint. But it comes with trade-offs that aren't immediately obvious. That database query runs on every request. If the query is slow, your entire page is slow. You can't show a loading spinner while data fetches because the component doesn't render until the data arrives. And if you need to refetch that data based on user interaction? You're in for a surprise.

When Server Components Actually Make Sense

After six months of production use, I've identified specific scenarios where Server Components are genuinely superior to Client Components. These aren't theoretical—they're patterns we've successfully deployed and maintained.

Static or Rarely-Changing Content

The most straightforward use case is content that doesn't change often or doesn't depend on user interaction. Think blog posts, documentation, marketing pages, or dashboard layouts. We migrated our entire documentation site to Server Components and saw immediate benefits.

Before the migration, our docs site shipped 247KB of JavaScript (gzipped). After moving to Server Components, that dropped to 43KB. The initial page load went from 1.8 seconds to 0.4 seconds on a 3G connection. These aren't trivial improvements—they're the difference between users bouncing and users staying.

Here's how we structure a typical documentation page now:

// app/docs/[slug]/page.js
import { getDocContent, getTableOfContents } from '@/lib/docs';
import { Markdown } from '@/components/Markdown';
import { TableOfContents } from '@/components/TableOfContents';

export default async function DocPage({ params }) {
  const content = await getDocContent(params.slug);
  const toc = await getTableOfContents(content);
  
  return (
    
      
        
      
      
        
      
    
  );
}

The TableOfContents component is a Client Component because it needs interactivity (highlighting the current section as you scroll). But the page itself and the markdown rendering are Server Components. This gives us the best of both worlds: fast initial render with progressive enhancement for interactive features.

Data Fetching Close to the Source

One pattern that's worked exceptionally well for us is fetching data in Server Components when that data lives in the same data center as your application server. We run our Next.js app on AWS in us-east-1, and our primary PostgreSQL database is in the same region. The latency between our app server and database is typically 1-3ms.

Compare that to our old architecture where the client (potentially anywhere in the world) would make an API request to our server, which would then query the database. The client-to-server latency alone could be 200-300ms for users in Asia or South America. By moving data fetching to the server, we eliminated an entire round trip.

Here's a real example from our analytics dashboard:

// app/analytics/page.js
import { db } from '@/lib/database';
import { MetricsChart } from './MetricsChart';
import { getCurrentUser } from '@/lib/auth';

export default async function AnalyticsPage() {
  const user = await getCurrentUser();
  
  // These queries run in parallel on the server
  const [pageViews, conversions, revenue] = await Promise.all([
    db.query('SELECT * FROM page_views WHERE user_id = $1 AND date > NOW() - INTERVAL \'30 days\'', [user.id]),
    db.query('SELECT * FROM conversions WHERE user_id = $1 AND date > NOW() - INTERVAL \'30 days\'', [user.id]),
    db.query('SELECT * FROM revenue WHERE user_id = $1 AND date > NOW() - INTERVAL \'30 days\'', [user.id])
  ]);

  return (
    
      
      
      
    
  );
}

The performance improvement here was dramatic. In our old setup, the client would make three separate API calls, each with its own round trip. Total time: ~900ms for users in the US, up to 2 seconds for international users. With Server Components, all three queries run in parallel on the server with 1-3ms latency each, and the rendered HTML is sent to the client. Total time: ~50ms of database time plus network latency for the HTML response.

But here's the gotcha we hit: this only works well when your queries are fast. We had one dashboard that included a complex aggregation query that took 800ms to run. With Server Components, users saw a blank page for 800ms because the component couldn't render until the query completed. We ended up splitting that into a separate component with Suspense boundaries, which I'll cover later.

SEO-Critical Pages

This one's obvious but worth emphasizing: if Google needs to see your content, use Server Components. We migrated our entire marketing site and blog to Server Components specifically for SEO benefits.

The difference is stark. With Client Components, the initial HTML sent to search engines is basically empty—just a div with an id and a script tag. The actual content only appears after JavaScript executes. While Google claims they can execute JavaScript, the reality is more complicated, and other search engines definitely struggle with it.

With Server Components, the HTML contains your actual content immediately. No JavaScript execution required. We saw our search rankings improve across the board after the migration, particularly for long-tail keywords.

Reducing Bundle Size for Large Dependencies

One unexpected benefit we discovered: Server Components let you use large dependencies without impacting bundle size. We have a feature that generates PDF reports using a library called pdfkit. This library is 287KB minified. In our old architecture, we had two bad options: either ship 287KB to every user (even those who never generate PDFs), or code-split aggressively and deal with loading states.

With Server Components, we moved PDF generation to the server:

// app/api/reports/generate/route.js
import PDFDocument from 'pdfkit';
import { db } from '@/lib/database';

export async function POST(request) {
  const { reportId } = await request.json();
  const data = await db.query('SELECT * FROM reports WHERE id = $1', [reportId]);
  
  const doc = new PDFDocument();
  // Generate PDF...
  
  return new Response(doc, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="report-${reportId}.pdf"`
    }
  });
}

Zero bytes added to the client bundle. The library only runs on the server where bundle size doesn't matter. This pattern works for any large dependency: image processing libraries, data visualization tools, complex parsers, etc.

When Client Components Are Still the Right Choice

Now for the other side of the equation. Despite the hype around Server Components, there are many scenarios where Client Components remain superior. Understanding these boundaries has been crucial for our team.

Any Form of Interactivity

This seems obvious, but it's worth being explicit: if your component needs to respond to user input, use a Client Component. Server Components can't use useState, useEffect, or event handlers. They can't respond to clicks, hovers, or keyboard input.

We learned this the hard way with our settings page. Initially, we tried to keep as much as possible in Server Components, including the form itself. We ended up with this awkward pattern:

// app/settings/page.js (Server Component)
export default async function SettingsPage() {
  const settings = await getSettings();
  return ;
}

// components/SettingsForm.js (Client Component)
'use client';

export function SettingsForm({ initialData }) {
  const [formData, setFormData] = useState(initialData);
  // Handle form logic...
}

This works, but it adds complexity. The Server Component fetches data, passes it to the Client Component as props, and the Client Component manages the interactive state. For simple forms, this overhead isn't worth it. We eventually moved the entire settings page to a Client Component and accepted the trade-off.

The rule we follow now: if a page is primarily interactive (forms, dashboards with filters, interactive charts), make it a Client Component from the start. Don't try to force Server Components into a use case they're not designed for.

Real-Time or Frequently-Updating Data

Server Components are request-based. They render once per request and send HTML to the client. If your data updates frequently, Server Components become awkward fast.

We have a real-time notification system that shows users when new events occur. Our first attempt used Server Components with aggressive revalidation:

// app/notifications/page.js
export const revalidate = 5; // Revalidate every 5 seconds

export default async function NotificationsPage() {
  const notifications = await getNotifications();
  return ;
}

This technically works—the page refetches data every 5 seconds. But the user experience is terrible. The entire page re-renders, scroll position resets, and there's no smooth transition when new notifications appear.

We switched to a Client Component with WebSocket updates:

'use client';

import { useEffect, useState } from 'react';
import { useWebSocket } from '@/hooks/useWebSocket';

export default function NotificationsPage() {
  const [notifications, setNotifications] = useState([]);
  const socket = useWebSocket();

  useEffect(() => {
    // Fetch initial data
    fetch('/api/notifications')
      .then(res => res.json())
      .then(setNotifications);

    // Listen for real-time updates
    socket.on('notification', (notification) => {
      setNotifications(prev => [notification, ...prev]);
    });
  }, [socket]);

  return ;
}

The user experience is dramatically better. New notifications slide in smoothly, scroll position is preserved, and updates feel instant. This is what Client Components excel at.

Third-Party Scripts and Browser APIs

If you need to integrate third-party scripts (analytics, chat widgets, payment processors) or use browser APIs (localStorage, geolocation, media devices), you need Client Components. Server Components have no access to browser globals like window, document, or localStorage.

Unlock Premium Content

You've read 30% of this article

What's in the full article

  • Complete step-by-step implementation guide
  • Working code examples you can copy-paste
  • Advanced techniques and pro tips
  • Common mistakes to avoid
  • Real-world examples and metrics

Join 10,000+ developers who love our premium content

Daniel Hartwell

Daniel Hartwell

Author

Covers backend systems, distributed architecture, and database performance. Contributing author at NextGenBeing.

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

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.