DEV Community

Cover image for React Server Components: Complete Deep Dive
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on • Originally published at iloveblogs.blog

React Server Components: Complete Deep Dive

React Server Components (RSC) represent a fundamental shift in how we build React applications. This guide covers everything you need to master RSC in Next.js 15, from core concepts to advanced patterns.

What Are React Server Components?

React Server Components are components that render exclusively on the server. Unlike traditional Server-Side Rendering (SSR), RSC components never hydrate on the client - they send only the rendered output.

Key Characteristics

  • Run only on the server (build time or request time)
  • Can directly access backend resources (databases, file systems, APIs)
  • Don't increase client-side JavaScript bundle
  • Cannot use browser APIs or React hooks
  • Can be async functions

Server Components vs Client Components

// Server Component (default in Next.js 15 App Router)
async function BlogPost({ id }) {
  // Direct database access - no API route needed
  const post = await db.posts.findById(id);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

// Client Component (needs 'use client' directive)
'use client';

import { useState } from 'react';

function LikeButton() {
  const [likes, setLikes] = useState(0);

  return (
    <button onClick={() => setLikes(likes + 1)}>
      Likes: {likes}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

RSC Architecture

The Component Tree

In Next.js 15, the component tree is divided into Server and Client boundaries:

App (Server)
├── Layout (Server)
│   ├── Header (Server)
│   │   └── Navigation (Client) ← 'use client'
│   └── Sidebar (Server)
└── Page (Server)
    ├── BlogPost (Server)
    └── Comments (Client) ← 'use client'
Enter fullscreen mode Exit fullscreen mode

Rendering Flow

  1. Server Components render first
  2. Server sends serialized component tree to client
  3. Client Components hydrate with interactivity
  4. React reconciles the tree

Data Fetching Patterns

Direct Database Access

Server Components can query databases directly without API routes:

// app/posts/[id]/page.jsx
import { db } from '@/lib/database';

export default async function PostPage({ params }) {
  const post = await db.query(
    'SELECT * FROM posts WHERE id = $1',
    [params.id]
  );

  return <article>{/* render post */}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Parallel Data Fetching

Fetch multiple data sources simultaneously:

async function Dashboard() {
  // These run in parallel
  const [user, posts, analytics] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchAnalytics()
  ]);

  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <Analytics data={analytics} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Sequential Data Fetching

When data depends on previous results:

async function UserDashboard({ userId }) {
  const user = await fetchUser(userId);
  // Wait for user before fetching their posts
  const posts = await fetchUserPosts(user.id);

  return (
    <div>
      <h1>{user.name}'s Posts</h1>
      <PostList posts={posts} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Streaming with Suspense

Stream components as data becomes available:

import { Suspense } from 'react';

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

      {/* This loads immediately */}
      <UserInfo />

      {/* These stream in as they resolve */}
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />
      </Suspense>

      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  );
}

async function Posts() {
  const posts = await fetchPosts(); // Slow query
  return <PostList posts={posts} />;
}
Enter fullscreen mode Exit fullscreen mode

Composition Patterns

Server Component with Client Children

Pass Client Components as children to Server Components:

// app/layout.jsx (Server Component)
import ClientSidebar from './ClientSidebar';

export default function Layout({ children }) {
  const data = await fetchData();

  return (
    <div>
      {/* Server Component can render Client Component */}
      <ClientSidebar data={data} />
      <main>{children}</main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Passing Server Components to Client Components

You cannot import Server Components into Client Components, but you can pass them as props:

// ❌ WRONG - Cannot import Server Component into Client Component
'use client';
import ServerComponent from './ServerComponent'; // Error!

// ✅ CORRECT - Pass as children or props
// app/page.jsx (Server Component)
import ClientWrapper from './ClientWrapper';
import ServerContent from './ServerContent';

export default function Page() {
  return (
    <ClientWrapper>
      <ServerContent /> {/* Passed as children */}
    </ClientWrapper>
  );
}

// ClientWrapper.jsx
'use client';
export default function ClientWrapper({ children }) {
  return <div className="wrapper">{children}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Shared Components

Some components work in both environments:

// components/Button.jsx
// No 'use client' - works in both contexts
export default function Button({ children, ...props }) {
  return (
    <button className="btn" {...props}>
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

Reduce Client-Side JavaScript

Keep components on the server when possible:

// ❌ BAD - Entire component is client-side
'use client';

export default function ProductPage({ product }) {
  const [quantity, setQuantity] = useState(1);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <img src={product.image} />
      <QuantitySelector value={quantity} onChange={setQuantity} />
    </div>
  );
}

// ✅ GOOD - Only interactive part is client-side
export default function ProductPage({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <img src={product.image} />
      <QuantitySelector /> {/* Only this is 'use client' */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Preload Data

Use React's preload pattern for critical data:

import { preload } from 'react-dom';

// Preload data before component renders
export default async function Page({ params }) {
  preload(fetchUser(params.id));
  preload(fetchPosts(params.id));

  return <UserProfile userId={params.id} />;
}
Enter fullscreen mode Exit fullscreen mode

Cache Data Fetching

Use Next.js caching strategies:

// Cache for 1 hour
async function fetchPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 }
  });
  return res.json();
}

// Cache indefinitely (until revalidated)
async function fetchStaticData() {
  const res = await fetch('https://api.example.com/static', {
    cache: 'force-cache'
  });
  return res.json();
}

// Never cache
async function fetchDynamicData() {
  const res = await fetch('https://api.example.com/dynamic', {
    cache: 'no-store'
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Streaming and Suspense

Progressive Rendering

Stream content as it becomes available:

// app/dashboard/page.jsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      {/* Renders immediately */}
      <Header />

      {/* Streams in when ready */}
      <Suspense fallback={<Skeleton />}>
        <SlowComponent />
      </Suspense>

      {/* Multiple suspense boundaries */}
      <div className="grid">
        <Suspense fallback={<CardSkeleton />}>
          <RevenueCard />
        </Suspense>
        <Suspense fallback={<CardSkeleton />}>
          <UsersCard />
        </Suspense>
        <Suspense fallback={<CardSkeleton />}>
          <OrdersCard />
        </Suspense>
      </div>
    </div>
  );
}

async function RevenueCard() {
  const revenue = await fetchRevenue(); // 2s
  return <Card title="Revenue" value={revenue} />;
}

async function UsersCard() {
  const users = await fetchUsers(); // 1s
  return <Card title="Users" value={users} />;
}
Enter fullscreen mode Exit fullscreen mode

Loading States

Create loading.jsx for automatic suspense boundaries:

// app/dashboard/loading.jsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Error Boundaries

Handle errors with error.jsx:

// app/dashboard/error.jsx
'use client'; // Error boundaries must be Client Components

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

Server Actions

Call server functions from Client Components:

// app/actions.js
'use server';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');

  await db.posts.create({ title, content });
  revalidatePath('/posts');
}

// app/new-post/page.jsx
import { createPost } from '../actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Imports

Lazy load Client Components:

import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false // Don't render on server
});

export default function Analytics() {
  return (
    <div>
      <h1>Analytics</h1>
      <HeavyChart />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Context with Server Components

Share data without prop drilling:

// app/providers.jsx
'use client';

import { createContext, useContext } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children, theme }) {
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);

// app/layout.jsx (Server Component)
import { ThemeProvider } from './providers';

export default async function Layout({ children }) {
  const theme = await fetchTheme();

  return (
    <html>
      <body>
        <ThemeProvider theme={theme}>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Metadata Generation

Generate dynamic metadata in Server Components:

// app/posts/[id]/page.jsx
export async function generateMetadata({ params }) {
  const post = await fetchPost(params.id);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

export default async function PostPage({ params }) {
  const post = await fetchPost(params.id);
  return <article>{/* render post */}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Common Patterns and Best Practices

1. Default to Server Components

Start with Server Components and only add 'use client' when needed:

// ✅ Server Component by default
export default async function Page() {
  const data = await fetchData();
  return <Content data={data} />;
}

// Only mark interactive parts as client
'use client';
export function InteractiveButton() {
  return <button onClick={() => alert('Clicked!')}>Click me</button>;
}
Enter fullscreen mode Exit fullscreen mode

2. Move Client Boundaries Down

Push 'use client' as deep as possible in the component tree:

// ❌ BAD - Entire page is client-side
'use client';

export default function Page() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <Header />
      <Content />
      <Counter count={count} setCount={setCount} />
    </div>
  );
}

// ✅ GOOD - Only counter is client-side
export default function Page() {
  return (
    <div>
      <Header />
      <Content />
      <Counter /> {/* This component has 'use client' */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Serialize Props

Props passed from Server to Client Components must be serializable:

// ❌ BAD - Cannot pass functions
<ClientComponent onClick={() => console.log('click')} />

// ✅ GOOD - Use Server Actions instead
'use server';
async function handleClick() {
  console.log('click');
}

<ClientComponent action={handleClick} />
Enter fullscreen mode Exit fullscreen mode

4. Use Suspense Boundaries Strategically

Don't wrap everything in Suspense - be intentional:

export default function Page() {
  return (
    <div>
      {/* Fast content renders immediately */}
      <Header />
      <Navigation />

      {/* Slow content streams in */}
      <Suspense fallback={<ContentSkeleton />}>
        <SlowContent />
      </Suspense>

      {/* Footer renders immediately */}
      <Footer />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

5. Avoid Request Waterfalls

Fetch data in parallel when possible:

// ❌ BAD - Sequential fetching
async function Page() {
  const user = await fetchUser();
  const posts = await fetchPosts(user.id); // Waits for user
  const comments = await fetchComments(posts[0].id); // Waits for posts

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

// ✅ GOOD - Parallel fetching
async function Page() {
  const userPromise = fetchUser();
  const postsPromise = fetchPosts();
  const commentsPromise = fetchComments();

  const [user, posts, comments] = await Promise.all([
    userPromise,
    postsPromise,
    commentsPromise
  ]);

  return <Dashboard user={user} posts={posts} comments={comments} />;
}
Enter fullscreen mode Exit fullscreen mode

Real-World Examples

E-commerce Product Page

// app/products/[id]/page.jsx
import { Suspense } from 'react';
import { AddToCartButton } from './AddToCartButton';
import { ReviewForm } from './ReviewForm';

export default async function ProductPage({ params }) {
  // Fetch product data on server
  const product = await fetchProduct(params.id);

  return (
    <div className="product-page">
      {/* Static content - Server Component */}
      <div className="product-info">
        <h1>{product.name}</h1>
        <img src={product.image} alt={product.name} />
        <p className="price">${product.price}</p>
        <p>{product.description}</p>
      </div>

      {/* Interactive - Client Component */}
      <AddToCartButton productId={product.id} />

      {/* Stream in reviews */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={product.id} />
      </Suspense>

      {/* Interactive form - Client Component */}
      <ReviewForm productId={product.id} />

      {/* Stream in recommendations */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations category={product.category} />
      </Suspense>
    </div>
  );
}

async function Reviews({ productId }) {
  const reviews = await fetchReviews(productId);
  return (
    <div className="reviews">
      {reviews.map(review => (
        <ReviewCard key={review.id} review={review} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dashboard with Real-time Updates

// app/dashboard/page.jsx
import { Suspense } from 'react';
import { RealtimeUpdates } from './RealtimeUpdates';

export default async function Dashboard() {
  // Fetch initial data on server
  const initialData = await fetchDashboardData();

  return (
    <div className="dashboard">
      <h1>Dashboard</h1>

      {/* Static metrics - Server Component */}
      <div className="metrics-grid">
        <MetricCard title="Total Users" value={initialData.totalUsers} />
        <MetricCard title="Revenue" value={initialData.revenue} />
        <MetricCard title="Orders" value={initialData.orders} />
      </div>

      {/* Real-time updates - Client Component */}
      <RealtimeUpdates initialData={initialData} />

      {/* Stream in charts */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

// RealtimeUpdates.jsx
'use client';

import { useEffect, useState } from 'react';

export function RealtimeUpdates({ initialData }) {
  const [data, setData] = useState(initialData);

  useEffect(() => {
    const ws = new WebSocket('wss://api.example.com/updates');

    ws.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };

    return () => ws.close();
  }, []);

  return (
    <div className="live-updates">
      <span className="live-indicator">● Live</span>
      <p>Active Users: {data.activeUsers}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Blog with Comments

// app/blog/[slug]/page.jsx
import { Suspense } from 'react';
import { CommentForm } from './CommentForm';
import { submitComment } from './actions';

export default async function BlogPost({ params }) {
  const post = await fetchPost(params.slug);

  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <time>{post.publishedAt}</time>
        <p>By {post.author}</p>
      </header>

      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      <section className="comments">
        <h2>Comments</h2>

        {/* Stream in comments */}
        <Suspense fallback={<CommentsSkeleton />}>
          <CommentsList postId={post.id} />
        </Suspense>

        {/* Interactive form */}
        <CommentForm postId={post.id} action={submitComment} />
      </section>
    </article>
  );
}

async function CommentsList({ postId }) {
  const comments = await fetchComments(postId);

  return (
    <div className="comments-list">
      {comments.map(comment => (
        <Comment key={comment.id} comment={comment} />
      ))}
    </div>
  );
}

// actions.js
'use server';

export async function submitComment(formData) {
  const postId = formData.get('postId');
  const content = formData.get('content');
  const author = formData.get('author');

  await db.comments.create({
    postId,
    content,
    author,
    createdAt: new Date()
  });

  revalidatePath(`/blog/${postId}`);
}
Enter fullscreen mode Exit fullscreen mode

Debugging and Troubleshooting

Common Errors

1. "You're importing a component that needs useState"

// ❌ Error: Using hooks in Server Component
export default function Page() {
  const [count, setCount] = useState(0); // Error!
  return <div>{count}</div>;
}

// ✅ Fix: Add 'use client'
'use client';

export default function Page() {
  const [count, setCount] = useState(0);
  return <div>{count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

2. "Functions cannot be passed to Client Components"

// ❌ Error: Passing function as prop
<ClientComponent onClick={() => console.log('click')} />

// ✅ Fix: Use Server Action
'use server';
async function handleClick() {
  console.log('click');
}

<ClientComponent action={handleClick} />
Enter fullscreen mode Exit fullscreen mode

3. "Cannot import Server Component into Client Component"

// ❌ Error
'use client';
import ServerComponent from './ServerComponent'; // Error!

// ✅ Fix: Pass as children
// Parent (Server Component)
<ClientComponent>
  <ServerComponent />
</ClientComponent>
Enter fullscreen mode Exit fullscreen mode

Debugging Tips

  1. Check component boundaries with React DevTools
  2. Use console.log to verify where code runs (server vs client)
  3. Inspect Network tab for RSC payload
  4. Use Next.js build output to see bundle sizes
  5. Enable verbose logging: NODE_OPTIONS='--inspect' next dev

Migration Guide

From Pages Router to App Router

// pages/posts/[id].jsx (Old)
export async function getServerSideProps({ params }) {
  const post = await fetchPost(params.id);
  return { props: { post } };
}

export default function Post({ post }) {
  return <article>{post.title}</article>;
}

// app/posts/[id]/page.jsx (New)
export default async function Post({ params }) {
  const post = await fetchPost(params.id);
  return <article>{post.title}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Adding Interactivity

// Before: Everything client-side
'use client';

export default function Page() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }, []);

  return <div>{data?.title}</div>;
}

// After: Server Component with Client interactivity
export default async function Page() {
  const data = await fetchData(); // Server-side

  return (
    <div>
      <h1>{data.title}</h1>
      <LikeButton postId={data.id} /> {/* Client Component */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Benchmarks

Bundle Size Reduction

Traditional CSR (Client-Side Rendering):
- Initial JS: 250 KB
- Hydration: 150ms
- Time to Interactive: 2.5s

With Server Components:
- Initial JS: 85 KB (-66%)
- Hydration: 50ms (-67%)
- Time to Interactive: 0.8s (-68%)
Enter fullscreen mode Exit fullscreen mode

Data Fetching Performance

API Route Pattern:
1. Client requests page (100ms)
2. Client requests API (150ms)
3. API queries database (200ms)
Total: 450ms

Server Component Pattern:
1. Server queries database (200ms)
2. Server renders component (50ms)
Total: 250ms (-44%)
Enter fullscreen mode Exit fullscreen mode

FAQ

What are React Server Components?

React Server Components (RSC) are components that render exclusively on the server, allowing you to fetch data, access backend resources directly, and reduce client-side JavaScript bundle size. They run during the build or on each request, sending only the rendered output to the client.

Can I use hooks in Server Components?

No, you cannot use React hooks (useState, useEffect, etc.) in Server Components because they don't have a client-side lifecycle. Hooks only work in Client Components marked with "use client" directive.

How do Server Components improve performance?

Server Components reduce bundle size by keeping component code on the server, enable faster data fetching by accessing databases directly without API routes, eliminate client-side waterfalls, and allow streaming HTML for faster perceived performance.

When should I use Client Components vs Server Components?

Use Server Components by default for data fetching, accessing backend resources, and static content. Use Client Components when you need interactivity (onClick, onChange), browser APIs, React hooks, or state management.

Can Server Components and Client Components work together?

Yes! Server Components can import and render Client Components. However, Client Components cannot import Server Components directly - instead, pass Server Components as children or props to Client Components.

Do Server Components replace API routes?

Not entirely, but they reduce the need for API routes. Use Server Components for data fetching during rendering. Use API routes for webhooks, third-party integrations, or when you need a public API endpoint.

How does caching work with Server Components?

Next.js automatically caches Server Component renders and fetch requests. You can control caching with revalidate, cache: 'no-store', or revalidatePath() for on-demand revalidation.

Can I use third-party libraries in Server Components?

Yes, but only if they don't use browser APIs or React hooks. Many libraries work in both environments. Check the library documentation or test it.

Related Guides

Conclusion

React Server Components represent a paradigm shift in React development. By default rendering on the server, they enable better performance, simpler data fetching, and smaller bundle sizes.

Key takeaways:

  1. Default to Server Components, add 'use client' only when needed
  2. Push client boundaries as deep as possible in the component tree
  3. Use Suspense for streaming and progressive rendering
  4. Fetch data in parallel to avoid waterfalls
  5. Server Components can render Client Components, but not vice versa
  6. Props between Server and Client must be serializable

Start with Server Components for your next Next.js 15 project and only reach for Client Components when you need interactivity. Your users will thank you with faster load times and better performance.


Originally published at https://www.iloveblogs.blog

Top comments (0)