Server Components in React: A Deep Dive with Next.js 14
Introduction
React Server Components (RSC) represent one of the most significant paradigm shifts in React's history. Introduced as an experimental feature and now stable in Next.js 14, Server Components fundamentally change how we think about building React applications by blurring the line between server and client.
Traditional React applications are entirely client-side: the server sends JavaScript bundles to the browser, which then executes React code to render the UI. This approach works but comes with trade-offs: large bundle sizes, slower initial page loads, and limited direct access to backend resources.
React Server Components solve these problems by allowing components to render on the server, with zero JavaScript sent to the client for those components. This results in:
- Smaller Bundle Sizes: Server Components don't contribute to the client JavaScript bundle
- Direct Backend Access: Query databases, read files, and access server-only resources directly
- Improved Performance: Faster initial page loads and better Core Web Vitals
- Automatic Code Splitting: Built-in optimization without manual intervention
- Enhanced Security: Keep sensitive logic and API keys on the server
In this comprehensive guide, we'll explore React Server Components in depth using Next.js 14's App Router. We'll cover the fundamentals, best practices, common patterns, and build real-world examples.
Understanding React Server Components
The Mental Model
React Server Components introduce a new mental model where your component tree can have components rendering in different environments:
┌─────────────────────────────────────┐
│ Your App │
│ │
│ ┌────────────────────────────┐ │
│ │ Server Component (RSC) │ │
│ │ • Runs on server │ │
│ │ • No JS sent to client │ │
│ │ • Can access backend │ │
│ │ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ Client Component │ │ │
│ │ │ • Runs in browser │ │ │
│ │ │ • Interactive │ │ │
│ │ │ • Has state/effects │ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
└─────────────────────────────────────┘
Key Concepts:
- Server Components (default): Run only on the server during build or request time
- Client Components (
'use client'
): Run on both server (for SSR) and client - Composition: Server Components can import Client Components, but not vice versa
- Data Flow: Props flow from Server to Client Components
Server Components vs Client Components
| Aspect | Server Components | Client Components |
|--------|-------------------|-------------------|
| Directive | None (default) | 'use client'
|
| Where they run | Server only | Server (SSR) + Client |
| Bundle size | 0 KB | Included in bundle |
| Data fetching | Direct (async/await) | useEffect/libraries |
| Browser APIs | ❌ No | ✅ Yes |
| Hooks | ❌ No | ✅ Yes |
| Event handlers | ❌ No | ✅ Yes |
| Backend access | ✅ Yes | ❌ No |
| Re-rendering | On navigation/refresh | On state/prop changes |
When to Use Each
Use Server Components for:
- Data fetching: Querying databases, calling APIs
- Backend logic: Processing data, file operations
- Static content: Blog posts, documentation
- Large dependencies: Using heavy libraries without client impact
- Security: Keeping sensitive tokens/keys secure
Use Client Components for:
- Interactivity: Buttons, forms, event handlers
- State management: useState, useReducer, useContext
- Effects: useEffect, useLayoutEffect
- Browser APIs: localStorage, geolocation, etc.
- Hooks: Any React hook (custom or built-in)
- Class components: All class components are client
Setting Up Next.js 14 with App Router
Project Initialization
Let's create a new Next.js 14 project with TypeScript:
# Create new Next.js app
npx create-next-app@latest server-components-demo --typescript --tailwind --app --no-src-dir
cd server-components-demo
# Install additional dependencies
npm install @prisma/client
npm install -D prisma
Answer the prompts:
- ✅ TypeScript: Yes
- ✅ ESLint: Yes
- ✅ Tailwind CSS: Yes
- ✅
app/
directory: Yes - ❌
src/
directory: No (optional) - ✅ Import alias: Yes (@/*)
Project Structure
server-components-demo/
├── app/
│ ├── api/
│ │ └── route.ts
│ ├── components/
│ │ ├── ServerComponent.tsx
│ │ └── ClientComponent.tsx
│ ├── dashboard/
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── layout.tsx
│ └── page.tsx
├── lib/
│ ├── db.ts
│ └── utils.ts
├── public/
├── next.config.js
├── package.json
└── tsconfig.json
App Router Basics
In Next.js 14's App Router, the file system determines your routes:
app/
├── page.tsx → / (homepage)
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/[slug]
└── dashboard/
├── layout.tsx → Layout for /dashboard/*
└── page.tsx → /dashboard
Building with Server Components
Basic Server Component
Server Components are the default in the App Router. Here's a simple example:
// app/posts/page.tsx
// No 'use client' directive = Server Component
interface Post {
id: number;
title: string;
content: string;
author: string;
createdAt: Date;
}
async function getPosts(): Promise<Post[]> {
// Direct database access - no API route needed!
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store', // or 'force-cache', or { next: { revalidate: 3600 } }
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function PostsPage() {
// Server Components can be async!
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Blog Posts</h1>
<div className="grid gap-6">
{posts.map((post) => (
<article key={post.id} className="border rounded-lg p-6">
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-4">{post.content}</p>
<div className="flex justify-between text-sm text-gray-500">
<span>By {post.author}</span>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
</div>
</article>
))}
</div>
</div>
);
}
Direct Database Access
One of the most powerful features of Server Components is direct database access:
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// app/users/page.tsx
import { prisma } from '@/lib/db';
interface User {
id: string;
name: string;
email: string;
posts: Array<{ id: string; title: string }>;
}
async function getUsers(): Promise<User[]> {
// Direct Prisma query - no API route needed!
return await prisma.user.findMany({
include: {
posts: {
take: 5,
orderBy: { createdAt: 'desc' },
},
},
});
}
export default async function UsersPage() {
const users = await getUsers();
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Users</h1>
<div className="grid gap-4">
{users.map((user) => (
<div key={user.id} className="border rounded-lg p-6">
<h2 className="text-xl font-semibold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
<div className="mt-4">
<h3 className="font-semibold mb-2">Recent Posts:</h3>
<ul className="list-disc list-inside">
{user.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
);
}
Data Fetching Patterns
Parallel Data Fetching
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function getUser() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
async function getAnalytics() {
const res = await fetch('https://api.example.com/analytics');
return res.json();
}
export default async function DashboardPage() {
// Parallel fetching - all three requests fire simultaneously
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
]);
return (
<div className="grid grid-cols-3 gap-4">
<UserCard user={user} />
<PostsList posts={posts} />
<AnalyticsPanel data={analytics} />
</div>
);
}
Sequential Data Fetching
// app/post/[id]/page.tsx
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
return res.json();
}
async function getComments(postId: string) {
const res = await fetch(`https://api.example.com/posts/${postId}/comments`);
return res.json();
}
export default async function PostPage({ params }: { params: { id: string } }) {
// Sequential: get post first
const post = await getPost(params.id);
// Then get comments using post data
const comments = await getComments(post.id);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
<CommentsSection comments={comments} />
</article>
);
}
Client Components: Adding Interactivity
Creating a Client Component
To make a component interactive, add the 'use client'
directive:
// app/components/LikeButton.tsx
'use client';
import { useState } from 'react';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export default function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const handleLike = async () => {
setIsLiked(!isLiked);
setLikes(isLiked ? likes - 1 : likes + 1);
// Call API to persist the like
await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
body: JSON.stringify({ liked: !isLiked }),
});
};
return (
<button
onClick={handleLike}
className={`px-4 py-2 rounded ${
isLiked ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
❤️ {likes} Likes
</button>
);
}
Composing Server and Client Components
Server Components can render Client Components:
// app/posts/[id]/page.tsx (Server Component)
import { prisma } from '@/lib/db';
import LikeButton from '@/app/components/LikeButton';
import CommentForm from '@/app/components/CommentForm';
async function getPost(id: string) {
return await prisma.post.findUnique({
where: { id },
include: { author: true },
});
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
if (!post) return <div>Post not found</div>;
return (
<article className="max-w-3xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<p className="text-gray-600 mb-8">By {post.author.name}</p>
<div className="prose mb-8">{post.content}</div>
{/* Client Component for interactivity */}
<LikeButton postId={post.id} initialLikes={post.likes} />
{/* Another Client Component */}
<CommentForm postId={post.id} />
</article>
);
}
Client Component Patterns
Lifting Client Components Up
Instead of making a whole component client-side, extract interactive parts:
// ❌ BAD: Entire component is client-side
'use client';
export default function ProductPage({ product }) {
const [count, setCount] = useState(1);
return (
<div>
<ProductDetails product={product} /> {/* Doesn't need client JS */}
<Reviews reviews={product.reviews} /> {/* Doesn't need client JS */}
<AddToCart count={count} setCount={setCount} /> {/* Needs interactivity */}
</div>
);
}
// ✅ GOOD: Only interactive part is client-side
// app/products/[id]/page.tsx (Server Component)
import { getProduct } from '@/lib/products';
import AddToCart from '@/components/AddToCart';
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<div>
<ProductDetails product={product} /> {/* Server Component */}
<Reviews reviews={product.reviews} /> {/* Server Component */}
<AddToCart productId={product.id} /> {/* Client Component */}
</div>
);
}
// components/AddToCart.tsx (Client Component)
'use client';
export default function AddToCart({ productId }) {
const [count, setCount] = useState(1);
// ... interactive logic
}
Passing Server Components as Props
You can pass Server Components as children to Client Components:
// components/ClientWrapper.tsx
'use client';
import { ReactNode } from 'react';
export default function ClientWrapper({ children }: { children: ReactNode }) {
// Client-side logic here
return <div className="wrapper">{children}</div>;
}
// app/page.tsx (Server Component)
import ClientWrapper from '@/components/ClientWrapper';
import ServerContent from '@/components/ServerContent';
export default function Page() {
return (
<ClientWrapper>
{/* This remains a Server Component! */}
<ServerContent />
</ClientWrapper>
);
}
Advanced Patterns and Techniques
Streaming and Suspense
Server Components work seamlessly with React Suspense for streaming:
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function SlowComponent() {
// Simulate slow data fetch
await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await fetch('https://api.example.com/slow');
const result = await data.json();
return <div>{result.content}</div>;
}
function LoadingSpinner() {
return <div className="animate-spin">⏳</div>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast content renders immediately */}
<QuickStats />
{/* Slow content streams in later */}
<Suspense fallback={<LoadingSpinner />}>
<SlowComponent />
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<AnotherSlowComponent />
</Suspense>
</div>
);
}
Loading States
Next.js provides a special loading.tsx
file for route-level loading states:
// app/posts/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto p-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
</div>
</div>
);
}
Error Handling
Use error.tsx
for error boundaries:
// app/posts/error.tsx
'use client'; // Error components must be Client Components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="container mx-auto p-8">
<h2 className="text-2xl font-bold text-red-600 mb-4">
Something went wrong!
</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={() => reset()}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Try again
</button>
</div>
);
}
Data Caching and Revalidation
Next.js extends fetch
with caching options:
// Static data - cached indefinitely
async function getStaticData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // Default
});
return res.json();
}
// Dynamic data - never cached
async function getDynamicData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
return res.json();
}
// Time-based revalidation
async function getRevalidatedData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // Revalidate every hour
});
return res.json();
}
// On-demand revalidation
// In an API route:
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST() {
revalidatePath('/posts'); // Revalidate specific path
revalidateTag('posts'); // Revalidate by tag
return Response.json({ revalidated: true });
}
Server Actions
Server Actions allow you to mutate data directly from components:
// app/posts/actions.ts
'use server';
import { prisma } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await prisma.post.create({
data: { title, content, authorId: 'user-id' },
});
revalidatePath('/posts');
}
export async function deletePost(postId: string) {
await prisma.post.delete({
where: { id: postId },
});
revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '../actions';
export default function NewPostPage() {
return (
<form action={createPost} className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Create New Post</h1>
<div className="mb-4">
<label className="block mb-2 font-semibold">Title</label>
<input
type="text"
name="title"
required
className="w-full px-4 py-2 border rounded"
/>
</div>
<div className="mb-4">
<label className="block mb-2 font-semibold">Content</label>
<textarea
name="content"
required
rows={10}
className="w-full px-4 py-2 border rounded"
/>
</div>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded"
>
Create Post
</button>
</form>
);
}
With client-side handling:
// components/DeleteButton.tsx
'use client';
import { useTransition } from 'react';
import { deletePost } from '@/app/posts/actions';
export default function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => deletePost(postId))}
disabled={isPending}
className="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}
Real-World Example: Blog Platform
Let's build a complete blog platform showcasing Server Components:
Database Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
avatar String?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String
published Boolean @default(false)
views Int @default(0)
likes Int @default(0)
author User @relation(fields: [authorId], references: [id])
authorId String
comments Comment[]
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(cuid())
content String
post Post @relation(fields: [postId], references: [id])
postId String
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
Home Page
// app/page.tsx
import { prisma } from '@/lib/db';
import Link from 'next/link';
import { Suspense } from 'react';
async function getFeaturedPosts() {
return await prisma.post.findMany({
where: { published: true },
include: {
author: true,
tags: true,
_count: { select: { comments: true } },
},
orderBy: { views: 'desc' },
take: 6,
});
}
async function FeaturedPosts() {
const posts = await getFeaturedPosts();
return (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="border rounded-lg overflow-hidden hover:shadow-lg transition"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">
By {post.author.name}
</span>
<div className="flex gap-4 text-gray-500">
<span>👁️ {post.views}</span>
<span>❤️ {post.likes}</span>
<span>💬 {post._count.comments}</span>
</div>
</div>
<div className="flex gap-2 mt-4">
{post.tags.map((tag) => (
<span
key={tag.id}
className="px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded"
>
{tag.name}
</span>
))}
</div>
</div>
</Link>
))}
</div>
);
}
function FeaturedPostsSkeleton() {
return (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="border rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>
);
}
export default function HomePage() {
return (
<main className="container mx-auto px-4 py-12">
<section className="text-center mb-16">
<h1 className="text-5xl font-bold mb-4">Welcome to Our Blog</h1>
<p className="text-xl text-gray-600">
Discover amazing stories and insights
</p>
</section>
<section>
<h2 className="text-3xl font-bold mb-8">Featured Posts</h2>
<Suspense fallback={<FeaturedPostsSkeleton />}>
<FeaturedPosts />
</Suspense>
</section>
</main>
);
}
Blog Post Page
// app/blog/[slug]/page.tsx
import { prisma } from '@/lib/db';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import LikeButton from '@/components/LikeButton';
import CommentSection from '@/components/CommentSection';
import ViewTracker from '@/components/ViewTracker';
async function getPost(slug: string) {
const post = await prisma.post.findUnique({
where: { slug, published: true },
include: {
author: true,
tags: true,
},
});
if (!post) return null;
// Increment view count
await prisma.post.update({
where: { id: post.id },
data: { views: { increment: 1 } },
});
return post;
}
async function getComments(postId: string) {
return await prisma.comment.findMany({
where: { postId },
include: { author: true },
orderBy: { createdAt: 'desc' },
});
}
async function getRelatedPosts(postId: string, tags: string[]) {
return await prisma.post.findMany({
where: {
id: { not: postId },
published: true,
tags: {
some: {
id: { in: tags },
},
},
},
take: 3,
include: { author: true },
});
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
};
}
async function Comments({ postId }: { postId: string }) {
const comments = await getComments(postId);
return (
<div className="space-y-4">
<h3 className="text-2xl font-bold">
Comments ({comments.length})
</h3>
{comments.map((comment) => (
<div key={comment.id} className="border-l-4 border-blue-500 pl-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold">{comment.author.name}</span>
<span className="text-sm text-gray-500">
{new Date(comment.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-gray-700">{comment.content}</p>
</div>
))}
</div>
);
}
async function RelatedPosts({ postId, tagIds }: { postId: string; tagIds: string[] }) {
const related = await getRelatedPosts(postId, tagIds);
if (related.length === 0) return null;
return (
<div className="mt-12">
<h3 className="text-2xl font-bold mb-6">Related Posts</h3>
<div className="grid md:grid-cols-3 gap-4">
{related.map((post) => (
<a
key={post.id}
href={`/blog/${post.slug}`}
className="border rounded-lg p-4 hover:shadow-lg transition"
>
<h4 className="font-semibold mb-2">{post.title}</h4>
<p className="text-sm text-gray-600">{post.excerpt}</p>
</a>
))}
</div>
</div>
);
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="container mx-auto px-4 py-12 max-w-4xl">
<header className="mb-8">
<h1 className="text-5xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600 mb-4">
<div className="flex items-center gap-2">
<span className="font-semibold">{post.author.name}</span>
</div>
<span>•</span>
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
<span>•</span>
<span>👁️ {post.views} views</span>
</div>
<div className="flex gap-2">
{post.tags.map((tag) => (
<span
key={tag.id}
className="px-3 py-1 bg-blue-100 text-blue-600 rounded-full text-sm"
>
{tag.name}
</span>
))}
</div>
</header>
<div className="prose prose-lg max-w-none mb-12">
{post.content}
</div>
<div className="border-t pt-8 mb-12">
<LikeButton postId={post.id} initialLikes={post.likes} />
</div>
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={post.id} />
</Suspense>
<CommentSection postId={post.id} />
<Suspense fallback={<div>Loading related posts...</div>}>
<RelatedPosts postId={post.id} tagIds={post.tags.map(t => t.id)} />
</Suspense>
</article>
);
}
Comment Form Component
// components/CommentSection.tsx
'use client';
import { useState, useTransition } from 'react';
import { submitComment } from '@/app/actions';
export default function CommentSection({ postId }: { postId: string }) {
const [content, setContent] = useState('');
const [isPending, startTransition] = useTransition();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
await submitComment(postId, content);
setContent('');
});
};
return (
<div className="mt-12">
<h3 className="text-2xl font-bold mb-4">Leave a Comment</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Share your thoughts..."
rows={4}
required
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isPending}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
</div>
);
}
Performance Optimization
Bundle Size Analysis
Server Components dramatically reduce bundle size:
// Before: Everything is client-side
'use client';
import { format } from 'date-fns'; // ~70KB
import { marked } from 'marked'; // ~45KB
import Prism from 'prismjs'; // ~30KB
export default function BlogPost({ post }) {
// ~145KB added to client bundle
const formatted = format(new Date(post.date), 'MMMM dd, yyyy');
const html = marked(post.content);
// ...
}
// After: Heavy processing on server
// No 'use client' = Server Component
import { format } from 'date-fns'; // 0KB to client
import { marked } from 'marked'; // 0KB to client
import Prism from 'prismjs'; // 0KB to client
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
// All heavy processing happens on server
const formatted = format(new Date(post.date), 'MMMM dd, yyyy');
const html = marked(post.content);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Streaming with Suspense Boundaries
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div className="grid grid-cols-2 gap-4">
{/* Fast content shows immediately */}
<div className="col-span-2">
<h1>Dashboard</h1>
</div>
{/* Each section streams independently */}
<Suspense fallback={<Skeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<Skeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<Skeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Caching Strategies
// lib/cache.ts
import { unstable_cache } from 'next/cache';
// Cache function results
export const getCachedPosts = unstable_cache(
async () => {
return await prisma.post.findMany({
where: { published: true },
include: { author: true },
});
},
['posts-list'],
{
revalidate: 3600, // 1 hour
tags: ['posts'],
}
);
// Use in component
export default async function PostsList() {
const posts = await getCachedPosts();
// ...
}
Best Practices and Common Pitfalls
Do's and Don'ts
✅ DO:
- Use Server Components by default - Only use Client Components when needed
- Fetch data where it's needed - Colocate data fetching with components
- Use Suspense for loading states - Better UX with streaming
- Keep sensitive logic on server - API keys, business logic
- Minimize Client Component boundaries - Lift them as high as possible
- Use Server Actions for mutations - Simpler than API routes
❌ DON'T:
- Don't add 'use client' unnecessarily - Increases bundle size
- Don't import Server Components into Client Components - Won't work
- Don't use hooks in Server Components - They're not supported
- Don't forget to handle errors - Use error.tsx files
- Don't over-fetch data - Be mindful of waterfall requests
- Don't ignore caching - Configure fetch caching appropriately
Common Mistakes
Mistake 1: Importing Server Components into Client Components
// ❌ WRONG
'use client';
import ServerComponent from './ServerComponent'; // Error!
export default function ClientComponent() {
return <ServerComponent />;
}
// ✅ RIGHT - Pass as children
// ClientComponent.tsx
'use client';
export default function ClientComponent({ children }) {
return <div className="wrapper">{children}</div>;
}
// page.tsx
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
);
}
Mistake 2: Using Hooks in Server Components
// ❌ WRONG
import { useState } from 'react';
export default async function ServerComponent() {
const [count, setCount] = useState(0); // Error!
const data = await fetchData();
return <div>{data}</div>;
}
// ✅ RIGHT - Extract interactive part
export default async function ServerComponent() {
const data = await fetchData();
return (
<div>
<div>{data}</div>
<InteractiveCounter /> {/* Client Component */}
</div>
);
}
// InteractiveCounter.tsx
'use client';
export default function InteractiveCounter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Mistake 3: Unnecessary Client Components
// ❌ WRONG - Making everything client-side
'use client';
export default function PostPage({ post }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} />
</article>
);
}
// ✅ RIGHT - Only interactive part is client-side
export default async function PostPage({ params }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} /> {/* Only this is client */}
</article>
);
}
Testing Server Components
Unit Testing
// __tests__/PostsList.test.tsx
import { render, screen } from '@testing-library/react';
import PostsList from '@/app/components/PostsList';
// Mock Prisma
jest.mock('@/lib/db', () => ({
prisma: {
post: {
findMany: jest.fn().mockResolvedValue([
{
id: '1',
title: 'Test Post',
content: 'Test content',
author: { name: 'John Doe' },
},
]),
},
},
}));
describe('PostsList', () => {
it('renders posts', async () => {
const PostsListResolved = await PostsList();
render(PostsListResolved);
expect(screen.getByText('Test Post')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
Integration Testing
// __tests__/integration/blog.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createPost } from '@/app/actions';
describe('Blog Integration', () => {
it('creates and displays a post', async () => {
const formData = new FormData();
formData.append('title', 'New Post');
formData.append('content', 'Post content');
await createPost(formData);
// Verify post was created
// ... assertions
});
});
Migration Guide
Migrating from Pages Router
Before (Pages Router):
// pages/posts/[id].tsx
import { GetServerSideProps } from 'next';
interface Props {
post: Post;
}
export default function PostPage({ post }: Props) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const post = await getPost(params.id as string);
return {
props: { post },
};
};
After (App Router with Server Components):
// app/posts/[id]/page.tsx
async function getPost(id: string) {
// Direct data fetching in component
return await prisma.post.findUnique({
where: { id },
});
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
Key Differences:
- No more
getServerSideProps
- fetch directly in component - Components can be
async
- Data fetching is colocated with component
- No props passing from special functions
Conclusion
React Server Components represent a fundamental shift in how we build React applications. By moving computation and data fetching to the server, we can build faster, more efficient applications with better user experiences.
Key Takeaways:
- Server Components are the default in Next.js 14 App Router - use them unless you need interactivity
- Smaller bundles - Server Components don't add to client JavaScript
- Direct backend access - Query databases, call APIs, read files directly
- Better performance - Faster initial loads, streaming, automatic code splitting
- Compose carefully - Server Components can render Client Components, but not vice versa
- Use Server Actions - Simplify mutations without API routes
- Embrace Suspense - Stream content for better perceived performance
When to Use Server Components:
- ✅ Data fetching and display
- ✅ Backend logic and processing
- ✅ Static content
- ✅ Large dependencies
- ✅ Security-sensitive operations
When to Use Client Components:
- ✅ Interactivity (onClick, onChange, etc.)
- ✅ State management (useState, useContext)
- ✅ Effects (useEffect, useLayoutEffect)
- ✅ Browser APIs (localStorage, geolocation)
- ✅ Custom hooks
Server Components are not just a new feature - they're a new way of thinking about React applications. By understanding their mental model and following best practices, you can build applications that are faster, more maintainable, and provide better user experiences.
The future of React is server-first, and Next.js 14 makes it easier than ever to take advantage of this paradigm shift. Start building with Server Components today and experience the difference they make.
Resources
Happy coding! 🚀