Server Components: The Future Is Hybrid

SS Saurav Sitaula

Deep dive into React Server Components (RSC) - components that render on the server with zero client JavaScript. Learn 'use client' directive, server vs client components, the composition pattern, streaming, and data fetching without useEffect. Complete guide for Next.js App Router architecture.

The Bundle Size Problem I Couldn’t Solve

After learning about Suspense and optimizing my app’s loading experience, I hit a wall: bundle size.

My dashboard had grown. It used:

  • A markdown parser for user content
  • A date formatting library
  • Syntax highlighting for code snippets
  • Chart libraries for analytics

All this JavaScript shipped to the browser. Users downloaded it, parsed it, executed it—just to show some formatted text.

import { marked } from 'marked';          // 35KB
import { formatDate } from 'date-fns';    // 20KB (tree-shaken)
import hljs from 'highlight.js';           // 80KB
import { Chart } from 'chart.js';          // 60KB

function BlogPost({ content, date }) {
  const html = marked(content);
  const formattedDate = formatDate(date, 'PPP');
  
  return (
    <article>
      <time>{formattedDate}</time>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  );
}

That’s 200KB of JavaScript. For what? To transform markdown that doesn’t change and format a date that doesn’t change.

What if we could run this on the server, send the HTML, and ship zero JavaScript?

Enter: React Server Components

React Server Components (RSC) are components that run exclusively on the server. They:

  • Never ship to the client bundle
  • Can directly access backend resources (databases, filesystems)
  • Stream HTML to the client
  • Have zero client-side JavaScript cost
// This component runs ONLY on the server
// The imports never reach the client bundle

import { marked } from 'marked';
import { formatDate } from 'date-fns';
import hljs from 'highlight.js';

async function BlogPost({ slug }) {
  // Direct database access - no API needed!
  const post = await db.posts.findOne({ slug });
  
  const html = marked(post.content);
  const formattedDate = formatDate(post.date, 'PPP');
  
  return (
    <article>
      <time>{formattedDate}</time>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  );
}

The markdown parser, date library, and syntax highlighter? They run on the server. The client receives HTML. Bundle size impact: zero.

Server Components vs Client Components

Here’s the mental model shift:

Server Components (default in Next.js App Router):

  • Run on the server only
  • Can be async (use await directly!)
  • Can access backend resources
  • Cannot use hooks (useState, useEffect)
  • Cannot use browser APIs
  • Ship no JavaScript to client

Client Components (opt-in with 'use client'):

  • Run on client (and server for SSR)
  • Can use hooks and state
  • Can use browser APIs
  • Can handle user interactions
  • Ship JavaScript to client
// app/page.js - Server Component by default
import { ClientCounter } from './ClientCounter';

async function Page() {
  // This runs on the server
  const data = await fetchFromDatabase();
  
  return (
    <div>
      <h1>Server rendered: {data.title}</h1>
      <ClientCounter />  {/* Interactive part */}
    </div>
  );
}

// app/ClientCounter.js
'use client';  // This directive makes it a Client Component

import { useState } from 'react';

export function ClientCounter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

The “use client” Boundary

The 'use client' directive marks where the server/client boundary begins. Everything imported by a Client Component is also part of the client bundle.

// ServerComponent.jsx - Server Component (no directive)
import { ClientComponent } from './ClientComponent';

function ServerComponent() {
  return (
    <div>
      <ServerOnlyStuff />  {/* Server Component */}
      <ClientComponent />   {/* Client boundary starts here */}
    </div>
  );
}

// ClientComponent.jsx
'use client';

import { someLibrary } from 'some-library';  // This ships to client

export function ClientComponent() {
  // Everything here is client-side
}

Think of it like an airlock. Server Components are in the vacuum of space (server). Client Components are in the pressurized cabin (browser). The 'use client' directive is the airlock door.

When to Use Which

This took me a while to internalize:

Use Server Components when:

  • Displaying data from a database or API
  • Using server-only libraries (database clients, ORMs)
  • Processing that doesn’t need client interaction
  • Heavy dependencies (markdown, syntax highlighting)
  • Accessing sensitive data (API keys, tokens)

Use Client Components when:

  • Interactivity is needed (click handlers, forms)
  • Using hooks (useState, useEffect, useContext)
  • Using browser APIs (window, localStorage)
  • Using event listeners
  • Using React class components with lifecycle

The Composition Pattern

Here’s the pattern that clicks: Server Components can render Client Components, but not vice versa (directly).

// This works: Server wrapping Client
// page.jsx (Server Component)
import { InteractiveWidget } from './InteractiveWidget';

async function Page() {
  const data = await fetchData();
  
  return (
    <div>
      <StaticContent data={data} />
      <InteractiveWidget initialData={data} />
    </div>
  );
}

// InteractiveWidget.jsx
'use client';

export function InteractiveWidget({ initialData }) {
  const [state, setState] = useState(initialData);
  // ... interactive logic
}

But what if a Client Component needs to render server content? Use the children pattern:

// page.jsx (Server Component)
import { ClientTabs } from './ClientTabs';
import { TabContent } from './TabContent';  // Server Component

async function Page() {
  return (
    <ClientTabs>
      <TabContent />  {/* Server-rendered, passed as children */}
    </ClientTabs>
  );
}

// ClientTabs.jsx
'use client';

export function ClientTabs({ children }) {
  const [activeTab, setActiveTab] = useState(0);
  
  return (
    <div>
      <div className="tabs">
        <button onClick={() => setActiveTab(0)}>Tab 1</button>
        <button onClick={() => setActiveTab(1)}>Tab 2</button>
      </div>
      <div className="content">
        {children}  {/* Server content rendered inside Client Component! */}
      </div>
    </div>
  );
}

The children are rendered on the server and passed as a prop. The Client Component just slots them in.

Data Fetching Gets Simpler

Remember all that useEffect data fetching code? In Server Components, you just… fetch:

// Old way (Client Component)
'use client';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchUser(userId).then(setUser).finally(() => setLoading(false));
  }, [userId]);
  
  if (loading) return <Skeleton />;
  return <Profile user={user} />;
}

// New way (Server Component)
async function UserProfile({ userId }) {
  const user = await fetchUser(userId);  // Just await!
  return <Profile user={user} />;
}

No state. No effect. No loading state (Suspense handles it automatically). Just async/await.

Real-World Example: A Blog With Comments

Let me show a full example combining Server and Client Components:

// app/posts/[slug]/page.jsx - Server Component
import { Comments } from './Comments';
import { CommentForm } from './CommentForm';
import { db } from '@/lib/db';
import { marked } from 'marked';

export default async function PostPage({ params }) {
  const post = await db.posts.findOne({ slug: params.slug });
  const comments = await db.comments.find({ postId: post.id });
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.date).toLocaleDateString()}</time>
      <div 
        className="prose"
        dangerouslySetInnerHTML={{ __html: marked(post.content) }}
      />
      
      <section className="comments">
        <h2>Comments ({comments.length})</h2>
        <Comments comments={comments} />  {/* Server Component */}
        <CommentForm postId={post.id} />  {/* Client Component */}
      </section>
    </article>
  );
}

// Comments.jsx - Server Component (no directive needed)
export function Comments({ comments }) {
  return (
    <ul>
      {comments.map(comment => (
        <li key={comment.id}>
          <strong>{comment.author}</strong>
          <p>{comment.text}</p>
          <time>{new Date(comment.date).toLocaleDateString()}</time>
        </li>
      ))}
    </ul>
  );
}

// CommentForm.jsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export function CommentForm({ postId }) {
  const [text, setText] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const router = useRouter();
  
  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    
    await fetch('/api/comments', {
      method: 'POST',
      body: JSON.stringify({ postId, text }),
    });
    
    setText('');
    setSubmitting(false);
    router.refresh();  // Refetch server components!
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <textarea 
        value={text} 
        onChange={e => setText(e.target.value)}
        placeholder="Add a comment..."
      />
      <button type="submit" disabled={submitting}>
        {submitting ? 'Posting...' : 'Post Comment'}
      </button>
    </form>
  );
}

What ships to the client:

  • CommentForm.jsx and its React code
  • React core for interactivity

What stays on the server:

  • marked library (0KB client impact)
  • Database queries
  • All the static rendering logic

Streaming and Suspense

Server Components + Suspense enable streaming:

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

export default function Page() {
  return (
    <div>
      <Header />  {/* Sent immediately */}
      
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />  {/* Streamed when ready */}
      </Suspense>
      
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />  {/* Streamed when ready */}
      </Suspense>
      
      <Footer />  {/* Sent immediately */}
    </div>
  );
}

async function Posts() {
  const posts = await fetchPosts();  // Takes 500ms
  return <PostList posts={posts} />;
}

async function Comments() {
  const comments = await fetchComments();  // Takes 1000ms
  return <CommentList comments={comments} />;
}

The browser receives:

  1. Instant: Header + skeletons + Footer
  2. After 500ms: Posts stream in, replacing skeleton
  3. After 1000ms: Comments stream in, replacing skeleton

Users see content progressively. No waiting for the slowest query.

The Gotchas I Hit

1. Can’t Pass Functions as Props to Client Components

// ❌ This doesn't work
function ServerComponent() {
  const handleClick = () => console.log('clicked');
  
  return <ClientButton onClick={handleClick} />;  // Error!
}

Functions can’t be serialized across the server/client boundary. Solutions:

  • Move the handler into the Client Component
  • Use Server Actions for server-side logic

2. Context Doesn’t Cross the Boundary Directly

// ❌ This doesn't work as expected
'use client';
const ThemeContext = createContext();

// Server Component can't use useContext
function ServerComponent() {
  const theme = useContext(ThemeContext);  // Error!
}

Context is client-only. Server Components can render providers, but can’t consume them.

3. Third-Party Libraries Often Need ‘use client’

Many libraries use hooks internally:

'use client';

import { motion } from 'framer-motion';  // Uses hooks internally

export function AnimatedDiv({ children }) {
  return <motion.div>{children}</motion.div>;
}

Check if your libraries are Server Component compatible.

What I Wish I’d Known Earlier

  1. Default to Server Components. Only add 'use client' when you actually need interactivity or hooks. Start with the smallest client boundary possible.

  2. Bundle analysis matters more than ever. Use tools like @next/bundle-analyzer to see what’s shipping to the client.

  3. Composition is key. Pass Server Components as children to Client Components when you need server data inside interactive wrappers.

  4. Streaming changes UX patterns. Design for progressive loading. Show skeletons for slow parts, instant content for fast parts.

  5. Server Actions complement Server Components. For form submissions and mutations, look into Server Actions—they complete the picture.

  6. This is the future, but it’s still evolving. The patterns are stabilizing, but keep learning. The ecosystem is catching up.

The Journey So Far

Looking back at where I started—copying navigation bars across HTML files—it’s been a wild ride.

From components to state. From state to lifecycle. From lifecycle to hooks. From hooks to custom hooks. From custom hooks to context. From context to performance optimization. From performance to error boundaries. From error boundaries to Suspense. And now, Server Components.

Each concept built on the last. Each solved problems the previous approach couldn’t.

React keeps evolving, and so do I.


P.S. — The dashboard that started this whole journey? I rebuilt it with Server Components. The initial JavaScript bundle dropped from 400KB to 120KB. Same features. Same interactivity where needed. One-third the JavaScript. That’s not optimization—that’s a paradigm shift.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism