Back to Blog
8 min readTutorial

React Server Components Explained: The Future of React

Understand React Server Components, how they work, and why they're revolutionizing the way we build React applications

React Server Components Explained: The Future of React

React Server Components (RSC) represent a paradigm shift in how we build React applications. They enable server-side rendering of components without sending JavaScript to the client, dramatically improving performance and user experience.

What Are React Server Components?

React Server Components are components that render exclusively on the server. Unlike traditional Server-Side Rendering (SSR), RSCs don't send their JavaScript code to the client at all.

Key Differences from Traditional SSR

// Traditional SSR: Renders on server, hydrates on client
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div>{user?.name}</div>;
}

// Server Component: Renders only on server
async function UserProfile({ userId }) {
  const user = await fetchUser(userId);
  return <div>{user.name}</div>;
}

Traditional SSR:

  1. Server renders HTML
  2. Client receives HTML + JavaScript
  3. Client hydrates (attaches event handlers)
  4. Full component code ships to client

React Server Components:

  1. Server renders component
  2. Client receives serialized output (not HTML or JS)
  3. No hydration needed for server components
  4. Zero JavaScript sent for server components

Benefits of Server Components

1. Zero-Bundle-Size Components

Server Components don't add to your JavaScript bundle. Heavy dependencies stay on the server.

// Server Component - markdown-it doesn't ship to client
import markdownIt from 'markdown-it'; // Heavy library

async function BlogPost({ slug }) {
  const post = await fetchPost(slug);
  const md = markdownIt();
  const html = md.render(post.content);

  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

2. Direct Backend Access

Server Components can access databases, filesystems, and APIs directly without creating API routes.

// Server Component - direct database access
import { db } from './database';

async function UserList() {
  const users = await db.users.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10
  });

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

3. Automatic Code Splitting

React automatically code-splits between Server and Client Components.

// Server Component
import ClientButton from './ClientButton'; // Only this gets sent to client

async function ProductPage({ id }) {
  const product = await fetchProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ClientButton productId={id} />
    </div>
  );
}

4. Improved Performance

By rendering on the server and sending less JavaScript, pages load faster.

Server vs Client Components

Server Components (Default)

Can:

  • Fetch data directly
  • Access backend resources
  • Keep sensitive information secure
  • Use large dependencies
  • Reduce client bundle size

Cannot:

  • Use React hooks (useState, useEffect, etc.)
  • Use browser APIs
  • Handle user interactions directly
  • Maintain state
// Server Component (default in Next.js 13+ app directory)
async function BlogList() {
  const posts = await getPosts();

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

Client Components

Can:

  • Use React hooks
  • Handle user interactions
  • Use browser APIs
  • Maintain state and lifecycle

Cannot:

  • Be async functions
  • Access backend directly (need API routes)
'use client'; // Explicitly mark as Client Component

import { useState } from 'react';

function SearchBar() {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

Composition Patterns

Pattern 1: Server Component with Client Component Children

// ServerWrapper.js - Server Component
import ClientCounter from './ClientCounter';

async function ServerWrapper() {
  const initialCount = await fetchInitialCount();

  return (
    <div>
      <h1>Server-rendered content</h1>
      <ClientCounter initialCount={initialCount} />
    </div>
  );
}

// ClientCounter.js - Client Component
'use client';
import { useState } from 'react';

export default function ClientCounter({ initialCount }) {
  const [count, setCount] = useState(initialCount);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

Pattern 2: Passing Server Components as Props

You can pass Server Components to Client Components as children or props.

// Layout.js - Client Component
'use client';

export default function Layout({ children }) {
  return (
    <div className="layout">
      <nav>{/* client-side navigation */}</nav>
      <main>{children}</main>
    </div>
  );
}

// Page.js - Server Component
async function Page() {
  const data = await fetchData();

  return (
    <Layout>
      <ServerContent data={data} />
    </Layout>
  );
}

Pattern 3: Context Providers

Context Providers must be Client Components, but can wrap Server Components.

// ThemeProvider.js - Client Component
'use client';

import { createContext, useState } from 'react';

export const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

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

// Layout.js - Server Component
import { ThemeProvider } from './ThemeProvider';

function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Data Fetching Patterns

Sequential Fetching (Waterfall)

// ⚠️ Sequential - slower
async function Page() {
  const user = await fetchUser();
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);

  return <div>{/* render */}</div>;
}

Parallel Fetching

// ✅ Parallel - faster
async function Page() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ]);

  return <div>{/* render */}</div>;
}

Streaming with Suspense

import { Suspense } from 'react';

function Page() {
  return (
    <div>
      <h1>My Page</h1>
      <Suspense fallback={<LoadingSpinner />}>
        <ServerComponent />
      </Suspense>
    </div>
  );
}

async function ServerComponent() {
  const data = await fetchSlowData();
  return <div>{data}</div>;
}

Best Practices

1. Keep Client Components Small

Move interactivity as deep as possible in your component tree.

// ❌ Bad: Entire page is client component
'use client';

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

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <button onClick={() => setQuantity(q => q + 1)}>
        Add to cart: {quantity}
      </button>
    </div>
  );
}

// ✅ Good: Only interactive part is client component
async function ProductPage({ productId }) {
  const product = await fetchProduct(productId);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton />
    </div>
  );
}

// Small client component
'use client';
function AddToCartButton() {
  const [quantity, setQuantity] = useState(1);
  return (
    <button onClick={() => setQuantity(q => q + 1)}>
      Add to cart: {quantity}
    </button>
  );
}

2. Use Server Components for Data Fetching

Fetch data in Server Components to avoid client-side waterfalls.

// ✅ Good: Fetch in server component
async function UserDashboard({ userId }) {
  const userData = await fetchUser(userId);
  const analytics = await fetchAnalytics(userId);

  return (
    <div>
      <UserProfile data={userData} />
      <AnalyticsChart data={analytics} />
    </div>
  );
}

3. Serialize Props Correctly

Only serializable data can pass from Server to Client Components.

// ❌ Bad: Cannot pass functions
function ServerParent() {
  const handleClick = () => console.log('clicked');

  return <ClientChild onClick={handleClick} />; // Error!
}

// ✅ Good: Define function in client component
function ServerParent() {
  return <ClientChild />;
}

// ClientChild.js
'use client';
function ClientChild() {
  const handleClick = () => console.log('clicked');
  return <button onClick={handleClick}>Click</button>;
}

4. Cache Data Fetches

Use React's cache function to deduplicate requests.

import { cache } from 'react';

const getUser = cache(async (id) => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
});

async function UserProfile({ userId }) {
  const user = await getUser(userId);
  return <div>{user.name}</div>;
}

async function UserPosts({ userId }) {
  const user = await getUser(userId); // Same request, cached!
  return <div>Posts by {user.name}</div>;
}

Common Pitfalls

1. Importing Client Components into Server Components

// ❌ Bad: Can accidentally make server component a client component
import ClientComponent from './ClientComponent'; // Has 'use client'

function ServerComponent() {
  return <ClientComponent />;
}

2. Using Hooks in Server Components

// ❌ Bad: Cannot use hooks in server components
async function ServerComponent() {
  const [state, setState] = useState(0); // Error!
  return <div>{state}</div>;
}

3. Trying to Pass Non-Serializable Props

// ❌ Bad: Cannot pass Date objects, functions, etc.
function ServerComponent() {
  return <ClientComponent date={new Date()} />; // Error!
}

// ✅ Good: Pass serializable data
function ServerComponent() {
  return <ClientComponent date={new Date().toISOString()} />;
}

Using with Next.js App Router

Next.js 13+ App Router fully supports Server Components:

// app/page.js - Server Component by default
async function HomePage() {
  const posts = await fetchPosts();

  return (
    <main>
      <h1>My Blog</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </main>
  );
}

export default HomePage;

Loading States

// app/loading.js
export default function Loading() {
  return <div>Loading...</div>;
}

// app/page.js
async function Page() {
  const data = await fetchData(); // Shows loading.js while fetching
  return <div>{data}</div>;
}

Error Handling

// app/error.js
'use client';

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

Conclusion

React Server Components represent a fundamental shift in React architecture:

  • Better Performance: Less JavaScript, faster load times
  • Simplified Data Fetching: Direct backend access in components
  • Automatic Optimization: Code splitting and bundling handled for you
  • Flexible Composition: Mix server and client components as needed

The learning curve is worth it. Start by making most components Server Components by default, and only add 'use client' when you need interactivity. This approach will lead to faster, more efficient React applications.

The future of React is hybrid: server and client working together seamlessly.

👨‍💻

Jordan Patel

Web Developer & Technology Enthusiast