Steve Kinney

React Server Components

React Server Components (RSC) fundamentally change how we think about React applications by moving computation to the server and streaming interactive pieces to the client. Instead of shipping massive JavaScript bundles and making every component client-side by default, RSC lets you render components on the server—keeping heavy data fetching, third-party libraries, and business logic away from your user’s device while hydrating only the pieces that need interactivity.

If you’ve been wrestling with bundle sizes, slow initial loads, or the complexity of managing server state, RSC offers a compelling path forward. We’ll explore what Server Components are, how they differ from traditional approaches, and most importantly—when and how to use them effectively in your React 19 applications.

What Are React Server Components?

React Server Components run exclusively on the server and never hydrate on the client. They can directly access your database, file system, or any server-only APIs without the security concerns or bundle bloat that comes with client-side code. When a Server Component renders, React serializes its output and streams it to the client as a special format that can be seamlessly integrated with your Client Components.

Think of Server Components as a new rendering target—like how you might render React to a string with renderToString(), but instead of static HTML, you get a rich, interactive tree that can contain both server-rendered content and client-side components.

Here’s what makes them different from traditional server-side rendering (SSR):

  • Selective hydration: Only Client Components hydrate on the client
  • Streaming: Server Components stream as they resolve, improving perceived performance
  • Direct server access: No API layer needed for server-only data
  • Bundle elimination: Server-only dependencies never reach the client

Server vs Client Components in Practice

Let’s start with a practical example. Imagine you’re building a product page that needs to fetch data from your database and display an interactive shopping cart.

Traditional Approach (Everything Client-Side)

// ❌ Everything runs on the client
export default function ProductPage({ productId }: { productId: string }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then((res) => res.json())
      .then((data) => {
        setProduct(data);
        setLoading(false);
      });
  }, [productId]);

  if (loading) return <div>Loading...</div>;
  if (!product) return <div>Product not found</div>;

  return (
    <div>
      <ProductDetails product={product} />
      <AddToCartButton productId={product.id} />
    </div>
  );
}

Problems with this approach:

  • Loading states and error handling clutter your component
  • Network waterfall: HTML loads, then JS loads, then API call happens
  • Duplicate data structures between server and client
  • API endpoint needed just to bridge server data to client

Server Components Approach

// ✅ Server Component - runs only on the server
import { getProduct } from '@/lib/db';

export default async function ProductPage({ productId }: { productId: string }) {
  // Direct database access - no API layer needed
  const product = await getProduct(productId);

  if (!product) {
    return <div>Product not found</div>;
  }

  return (
    <div>
      <ProductDetails product={product} />
      {/* Only this component needs client-side interactivity */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

// Server Component - no interactivity needed
function ProductDetails({ product }: { product: Product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <img src={product.imageUrl} alt={product.name} />
    </div>
  );
}

// Client Component - needs onClick handler
('use client');
import { useState } from 'react';

export function AddToCartButton({ productId }: { productId: string }) {
  const [isAdding, setIsAdding] = useState(false);

  const handleAddToCart = async () => {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  };

  return (
    <button onClick={handleAddToCart} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

With Server Components:

  • No loading states needed—data is available immediately
  • Direct database queries without API endpoints
  • Smaller client bundle—only AddToCartButton ships to the client
  • Better SEO and initial paint performance

The “use client” Directive

The 'use client' directive is your explicit opt-in to client-side rendering. By default, components are Server Components in RSC-enabled frameworks like Next.js App Router.

Here’s how to think about the boundary:

// Server Component (default)
import { getUser } from '@/lib/auth';

export default async function Dashboard() {
  const user = await getUser();

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      {/* Server Component */}
      <UserProfile user={user} />
      {/* Client Component boundary starts here */}
      <InteractiveChart data={user.analytics} />
    </div>
  );
}

// Still a Server Component
function UserProfile({ user }: { user: User }) {
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <p>{user.bio}</p>
    </div>
  );
}

// Client Component - needs interactivity
('use client');
import { useState } from 'react';

export function InteractiveChart({ data }: { data: AnalyticsData }) {
  const [timeRange, setTimeRange] = useState('7d');

  return (
    <div>
      <select value={timeRange} onChange={(e) => setTimeRange(e.target.value)}>
        <option value="7d">Last 7 days</option>
        <option value="30d">Last 30 days</option>
      </select>
      <Chart data={data} timeRange={timeRange} />
    </div>
  );
}
Once you add

'use client' to a component, all of its children become Client Components too. Design your component boundaries thoughtfully to minimize what needs to run on the client.

Common Patterns and Best Practices

Pattern 1: Server Component Wrapper with Client Islands

Keep your Server Components at the top level and sprinkle in Client Components only where needed:

// Server Component - fetches data
export default async function PostPage({ slug }: { slug: string }) {
  const post = await getPost(slug);
  const comments = await getComments(post.id);

  return (
    <article>
      {/* Server-rendered content */}
      <PostContent post={post} />

      {/* Client islands for interactivity */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
      <ShareDialog url={post.url} title={post.title} />
      <CommentSection initialComments={comments} postId={post.id} />
    </article>
  );
}

Pattern 2: Streaming with Suspense

Server Components work beautifully with React’s Suspense to stream content as it becomes available:

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Fast content renders immediately */}
      <QuickStats />

      {/* Slow content streams in when ready */}
      <Suspense fallback={<div>Loading analytics...</div>}>
        <AnalyticsChart />
      </Suspense>

      <Suspense fallback={<div>Loading recent activity...</div>}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

// This might be slow due to complex queries
async function AnalyticsChart() {
  const data = await getAnalyticsData(); // Slow database query
  return <Chart data={data} />;
}

Pattern 3: Progressive Enhancement

Server Components excel at progressive enhancement—building experiences that work without JavaScript and enhance with it:

// Server Component renders the initial state
export default async function ProductList({ category }: { category: string }) {
  const products = await getProducts(category);

  return (
    <div>
      {/* Works without JavaScript */}
      <ProductGrid products={products} />

      {/* Enhanced with JavaScript */}
      <FilterControls category={category} />
      <LoadMoreButton />
    </div>
  );
}

// Client Component adds filtering without page reloads
('use client');
export function FilterControls({ category }: { category: string }) {
  const [filters, setFilters] = useState({});
  const router = useRouter();

  const handleFilterChange = (newFilters: any) => {
    setFilters(newFilters);
    // Update URL without page reload
    router.push(`/products/${category}?${new URLSearchParams(newFilters)}`);
  };

  return <div>{/* Filter controls */}</div>;
}

Performance Benefits and Tradeoffs

The Good

Smaller JavaScript bundles: Only interactive components ship to the client. In our product page example, instead of shipping React, your entire app code, and data fetching logic, you only ship the AddToCartButton component.

Faster initial page loads: Server Components render immediately on the server—no network requests needed for initial data.

Better Core Web Vitals: Less JavaScript means faster Time to Interactive (TTI) and better Cumulative Layout Shift (CLS) scores.

Direct server access: No need to create API endpoints just to fetch data for initial rendering.

The Tradeoffs

Framework complexity: RSC requires a framework like Next.js or Remix with proper build tooling. You can’t just drop Server Components into any React app.

Mental model shift: You need to think about server/client boundaries and understand the serialization limitations.

Debugging challenges: Stack traces might span server and client, making debugging more complex.

Limited ecosystem support: Not all React libraries work with Server Components yet.

Real-World Migration Strategy

If you’re considering RSC for an existing application, here’s a pragmatic approach:

Phase 1: Identify Server Component Candidates

Look for components that:

  • Fetch data on mount with useEffect
  • Don’t need user interactions (clicks, form inputs, etc.)
  • Render the same content for all users (or can be personalized server-side)
  • Import heavy libraries that could live on the server
// ✅ Great Server Component candidate
function BlogPost({ slug }: { slug: string }) {
  const [post, setPost] = useState(null);

  useEffect(() => {
    fetchPost(slug).then(setPost);
  }, [slug]);

  if (!post) return <div>Loading...</div>;

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Phase 2: Extract Client Islands

Identify the interactive pieces and separate them:

// Server Component
export default async function BlogPost({ slug }: { slug: string }) {
  const post = await getPost(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      {/* Client island */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  );
}

// Client Component - only ships interactive code
('use client');
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isLiking, setIsLiking] = useState(false);

  // Interactive logic here...
}

Phase 3: Gradually Expand Server Components

Once you’re comfortable with the patterns, gradually move more components to the server:

  • Static content components (headers, footers, navigation)
  • Data-heavy components (dashboards, reports, product listings)
  • SEO-critical pages (landing pages, blog posts, product pages)

Common Pitfalls and How to Avoid Them

Pitfall 1: Prop Serialization Limits

Server Components can only pass serializable data to Client Components:

// ❌ This won't work
function ServerComponent() {
  const handleClick = () => console.log('clicked');
  return <ClientComponent onClick={handleClick} />; // Functions aren't serializable
}

// ✅ Instead, handle events in Client Components
function ClientComponent({ data }: { data: SerializableData }) {
  const handleClick = () => console.log('clicked');
  return <button onClick={handleClick}>Click me</button>;
}

Pitfall 2: Over-Client Components

Don’t mark everything as 'use client' just to be safe:

// ❌ Unnecessary client boundary
'use client';
export default function Page() {
  return (
    <div>
      <StaticHeader /> {/* Could be Server Component */}
      <UserDashboard /> {/* Could be Server Component */}
      <InteractiveWidget /> {/* Actually needs client */}
    </div>
  );
}

// ✅ Only client components need the directive
export default function Page() {
  // Server Component
  return (
    <div>
      <StaticHeader /> {/* Server Component */}
      <UserDashboard /> {/* Server Component */}
      <InteractiveWidget /> {/* Client Component */}
    </div>
  );
}

Pitfall 3: Import Mixing

Be careful about importing server-only code in Client Components:

// ❌ This will cause build errors
'use client';
import { db } from '@/lib/database'; // Server-only import

export function ClientComponent() {
  // This won't work - db access is server-only
}

// ✅ Pass data from Server Component
export default async function ServerWrapper() {
  const data = await db.getUser(); // Server-only
  return <ClientComponent data={data} />;
}

('use client');
function ClientComponent({ data }: { data: User }) {
  // Use the pre-fetched data
}

When to Use Server Components

Server Components shine when:

  • Data-heavy interfaces: Dashboards, admin panels, reporting tools
  • Content-heavy pages: Blogs, documentation, marketing pages
  • E-commerce product pages: Where initial load speed is critical
  • Progressive web apps: Where you want fast initial loads with enhanced interactivity

They’re less ideal for:

  • Highly interactive applications: Real-time games, drawing apps, rich text editors
  • Client-side routing heavy apps: Where you need instant navigation
  • Apps with lots of personalized, dynamic content: That can’t be computed server-side

Last modified on .