FrontendReactNext.jsPerformance

React Server Components: A Practical Guide

Understanding when and how to use RSC in Next.js 15 for better performance, with real-world examples and common pitfalls.

Maulik Joshi

Full-Stack Developer

January 15, 2026
12 min read

React Server Components (RSC) represent the most significant shift in how we build React applications since hooks. They fundamentally change the rendering model — allowing components to execute on the server, reducing client-side JavaScript, and enabling direct database access from your UI layer. Let's break down what this means in practice.

The Mental Model Shift

Before RSC, every React component shipped to the client. Even if a component just rendered static text, its JavaScript was bundled, downloaded, parsed, and executed on the user's device. Server Components flip this: they render on the server and send only the HTML result — zero JavaScript for that component reaches the client.

Note

In Next.js App Router, all components are Server Components by default. You opt INTO client rendering with "use client", not the other way around.

Server vs Client: When to Use What

Here's the decision framework I use on every project:

decision-framework.md
text
1USE SERVER COMPONENT when:
2 ✓ Fetching data (database, API, file system)
3 ✓ Accessing backend resources directly
4 ✓ Rendering static or mostly-static content
5 ✓ Keeping sensitive data on the server (API keys, tokens)
6 ✓ Large dependencies that don't need to ship to client
7
8USE CLIENT COMPONENT when:
9 ✓ Using state (useState, useReducer)
10 ✓ Using effects (useEffect, useLayoutEffect)
11 ✓ Using browser APIs (localStorage, geolocation, etc.)
12 ✓ Using event handlers (onClick, onChange, etc.)
13 ✓ Using custom hooks that depend on state/effects
14 ✓ Using React Context providers

Practical Example: Blog Post Page

Let's build a blog post page that demonstrates the RSC pattern. The page itself is a Server Component that fetches data, while interactive elements are extracted into small Client Components:

app/blog/[slug]/page.tsx
tsx
1// This is a Server Component (default in App Router)
2// No "use client" directive needed
3
4import { notFound } from 'next/navigation';
5import { getPost, getRelatedPosts } from '@/lib/posts';
6import { LikeButton } from '@/components/LikeButton';
7import { ShareButton } from '@/components/ShareButton';
8import { Comments } from '@/components/Comments';
9
10type Props = {
11 params: Promise<{ slug: string }>;
12};
13
14export default async function BlogPost({ params }: Props) {
15 const { slug } = await params;
16 const post = await getPost(slug);
17
18 if (!post) notFound();
19
20 const related = await getRelatedPosts(post.tags, post.slug);
21
22 return (
23 <article className="max-w-3xl mx-auto">
24 {/* Static content — rendered on server, zero JS */}
25 <h1>{post.title}</h1>
26 <time>{post.publishedAt}</time>
27
28 <div dangerouslySetInnerHTML={{ __html: post.html }} />
29
30 {/* Interactive elements — only these ship JS */}
31 <div className="flex gap-4 mt-8">
32 <LikeButton postId={post.id} />
33 <ShareButton url={`/blog/${slug}`} title={post.title} />
34 </div>
35
36 {/* Client Component with its own data fetching */}
37 <Comments postId={post.id} />
38 </article>
39 );
40}

The Client Components

Notice how we push interactivity to the leaf nodes. The LikeButton is a tiny Client Component:

components/LikeButton.tsx
tsx
1'use client';
2
3import { useState, useTransition } from 'react';
4import { Heart } from 'lucide-react';
5import { likePost } from '@/actions/posts';
6
7export function LikeButton({ postId }: { postId: string }) {
8 const [liked, setLiked] = useState(false);
9 const [isPending, startTransition] = useTransition();
10
11 const handleLike = () => {
12 setLiked(!liked);
13 startTransition(async () => {
14 await likePost(postId);
15 });
16 };
17
18 return (
19 <button
20 onClick={handleLike}
21 disabled={isPending}
22 className="flex items-center gap-2"
23 >
24 <Heart
25 className={liked ? 'fill-red-500 text-red-500' : 'text-gray-400'}
26 />
27 {liked ? 'Liked' : 'Like'}
28 </button>
29 );
30}

The Composition Pattern

The most powerful RSC pattern is composition — passing Server Components as children to Client Components. This lets you keep server-rendered content inside interactive wrappers:

example-composition.tsx
tsx
1// Server Component
2import { Tabs } from '@/components/Tabs'; // Client Component
3import { getLatestPosts, getPopularPosts } from '@/lib/posts';
4
5export default async function BlogFeed() {
6 // These fetches happen on the server
7 const latest = await getLatestPosts();
8 const popular = await getPopularPosts();
9
10 return (
11 <Tabs defaultTab="latest">
12 {/* Server-rendered content passed as children */}
13 <Tabs.Panel id="latest">
14 {latest.map(post => (
15 <PostCard key={post.id} post={post} />
16 ))}
17 </Tabs.Panel>
18 <Tabs.Panel id="popular">
19 {popular.map(post => (
20 <PostCard key={post.id} post={post} />
21 ))}
22 </Tabs.Panel>
23 </Tabs>
24 );
25}
Tip

Think of the "use client" boundary like a waterfall: once you mark a component as client, all its imports become client too. Push client boundaries as deep (leaf-ward) as possible.

Common Pitfalls

1. Passing Non-Serializable Props

Server Components can pass props to Client Components, but those props must be serializable (JSON-safe). You can't pass functions, Dates, Maps, or class instances across the boundary:

common-mistake.tsx
tsx
1// ❌ This will error — functions are not serializable
2<ClientComponent onSubmit={handleSubmit} />
3
4// ❌ Dates are not serializable
5<ClientComponent date={new Date()} />
6
7// ✅ Pass serializable data, handle logic in client
8<ClientComponent dateString={post.date.toISOString()} />
9
10// ✅ Use Server Actions for function-like behavior
11<ClientComponent submitAction={submitFormAction} />

2. Unnecessary "use client"

The most common mistake is adding "use client" to components that don't need it. Every time you add it, you're opting that entire subtree out of server rendering and adding to the client bundle.

Warning

Audit your "use client" directives regularly. If a component only uses props and renders JSX (no state, no effects, no event handlers), remove "use client" — it's a Server Component.

Performance Impact

Here's what we measured when migrating a medium-sized Next.js app from Pages Router (all client) to App Router with proper RSC usage:

  • Client JS bundle: 340KB → 120KB (65% reduction)
  • LCP: 2.8s → 1.4s on 3G
  • Time to Interactive: 3.2s → 1.8s
  • Lighthouse Performance score: 72 → 96
  • Server render time: negligible increase (~50ms)

The goal isn't to make everything a Server Component. The goal is to use the right tool at each level — server for data and static content, client for interactivity.

Key Takeaways

  1. Default to Server Components — only add "use client" when you need interactivity
  2. Push client boundaries to leaf components for minimal JS bundles
  3. Use the composition pattern to mix server and client rendering
  4. Only pass serializable data across the server/client boundary
  5. Server Actions replace most API routes for mutations
  6. Measure before and after — the performance gains are real and significant

React Server Components aren't just a performance optimization — they're a better way to think about building web applications. By default, your app ships less JavaScript, loads faster, and is easier to reason about. The initial learning curve is worth it.

Newsletter

Stay updated with new posts

Get notified when I publish new articles. No spam, unsubscribe anytime.