React Server Components in Practice: Next-Gen React Architecture
What Are React Server Components and Why Do They Matter?
React Server Components (RSC) are a brand-new component model proposed by the React team, allowing components to render on the server and stream results directly to the client, fundamentally changing how React applications are architected.
Core Problems RSC Solves
| Problem | Traditional SPA | SSR | RSC |
|---|---|---|---|
| First-screen JS size | Large (full bundle) | Medium (needs hydration) | Small (client components only) |
| Data fetching | useEffect + API | getServerSideProps | Direct data source access in components |
| Bundle size | All deps in bundle | Same as SPA | Server deps: zero bundle |
| Request waterfall | Severe (serial client) | Medium (server parallel) | Server parallel + streaming |
Three Core Advantages of RSC
- Zero bundle size: Server component dependencies never enter the client bundle
- Direct data access: Read/write databases and file systems directly in components, no API layer needed
- Automatic code splitting: Client components naturally become code-split boundaries
💡 Use the JSON Formatter tool to check Next.js configuration files and ensure App Router settings are correct.
Server Components vs Client Components: Complete Comparison
Understanding the difference between Server and Client components is the first step to mastering RSC.
Core Differences
| Feature | Server Component | Client Component |
|---|---|---|
| Render environment | Server | Client (browser) |
| File suffix | .tsx (default) |
.tsx + "use client" |
| Data fetching | Direct DB/API/file access | useEffect / SWR / React Query |
| State management | No useState/useReducer | useState / useReducer / Context |
| Side effects | No useEffect / lifecycle | useEffect / useLayoutEffect |
| Dependency bundling | Not in client bundle | Included in client bundle |
| DOM access | None | Yes |
| Event handling | No onClick/onChange | Yes |
| Async support | async/await |
Requires third-party libraries |
Component Selection Decision Tree
Needs interaction (onClick / useState / useEffect)?
├── Yes → Client Component
└── No → Needs server resources (DB / file system / private API)?
├── Yes → Server Component
└── No → Need to reduce client bundle size?
├── Yes → Server Component
└── No → Server Component (default choice)
Practical Example: Server Component
// app/articles/page.tsx (Server Component, default)
import { db } from '@/lib/db';
interface Article {
id: number;
title: string;
content: string;
createdAt: Date;
}
export default async function ArticlesPage() {
const articles: Article[] = await db.query(
'SELECT id, title, content, created_at FROM articles ORDER BY created_at DESC LIMIT 20'
);
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Latest Articles</h1>
<div className="space-y-4">
{articles.map((article) => (
<article key={article.id} className="border rounded-lg p-4">
<h2 className="text-xl font-semibold">{article.title}</h2>
<p className="text-gray-600 mt-2">{article.content}</p>
<time className="text-sm text-gray-400">
{article.createdAt.toLocaleDateString('en-US')}
</time>
</article>
))}
</div>
</main>
);
}
Practical Example: Client Component
// app/components/LikeButton.tsx
'use client';
import { useState, useTransition } from 'react';
interface LikeButtonProps {
articleId: number;
initialLikes: number;
}
export default function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
const handleLike = async () => {
startTransition(async () => {
const res = await fetch(`/api/articles/${articleId}/like`, {
method: 'POST',
});
const data = await res.json();
setLikes(data.likes);
});
};
return (
<button
onClick={handleLike}
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? 'Processing...' : `Like (${likes})`}
</button>
);
}
"use client" and "use server" Directives Explained
These two directives are the boundary markers of the RSC architecture, determining the execution environment of code.
"use client" Directive
// app/components/SearchBar.tsx
'use client';
import { useState } from 'react';
export default function SearchBar() {
const [query, setQuery] = useState('');
return (
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search articles..."
className="border rounded px-3 py-2 flex-1"
/>
<button className="bg-blue-500 text-white px-4 py-2 rounded">
Search
</button>
</div>
);
}
"use server" Directive
// app/actions/article.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
throw new Error('Title and content are required');
}
await db.query(
'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
[title, content]
);
revalidatePath('/articles');
}
export async function deleteArticle(id: number) {
await db.query('DELETE FROM articles WHERE id = $1', [id]);
revalidatePath('/articles');
}
Directive Usage Rules
| Rule | Description |
|---|---|
"use client" must be at file top |
Declared before all imports |
"use server" must be at file top |
Declared before all imports, exported functions must be async |
| Server components can import Client components | But Client components cannot import Server components |
| Client components can receive Server components via props | Use children prop to pass |
"use client" is a boundary marker |
Marks the file and all its dependencies as client code |
Composition Pattern: Server Wrapping Client
// app/articles/page.tsx (Server Component)
import { db } from '@/lib/db';
import LikeButton from '@/components/LikeButton';
import SearchBar from '@/components/SearchBar';
export default async function ArticlesPage() {
const articles = await db.query('SELECT * FROM articles');
return (
<div>
<SearchBar />
{articles.map((article) => (
<article key={article.id}>
<h2>{article.title}</h2>
<p>{article.content}</p>
<LikeButton articleId={article.id} initialLikes={article.likes} />
</article>
))}
</div>
);
}
Data Fetching Patterns: Goodbye useEffect
The biggest paradigm shift in RSC is moving data fetching from client to server, eliminating useEffect request waterfalls entirely.
Traditional Pattern vs RSC Pattern
// Traditional: useEffect waterfall
'use client';
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
useEffect(() => {
if (!user) return;
fetch(`/api/users/${userId}/posts`)
.then(res => res.json())
.then(data => setPosts(data));
}, [userId, user]);
if (!user) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1>
{posts.map(post => <p key={post.id}>{post.title}</p>)}
</div>
);
}
// RSC: Server-side parallel fetching
import { db } from '@/lib/db';
export default async function UserProfile({ userId }: { userId: string }) {
const [user, posts] = await Promise.all([
db.query('SELECT * FROM users WHERE id = $1', [userId]),
db.query('SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC', [userId]),
]);
return (
<div>
<h1>{user.name}</h1>
{posts.map(post => <p key={post.id}>{post.title}</p>)}
</div>
);
}
Direct Database Access
// app/dashboard/page.tsx
import { db } from '@/lib/db';
export default async function DashboardPage() {
const [totalUsers, totalOrders, recentOrders] = await Promise.all([
db.query('SELECT COUNT(*) as count FROM users'),
db.query('SELECT COUNT(*) as count FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY'),
db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10'),
]);
return (
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-gray-500">Total Users</h3>
<p className="text-3xl font-bold">{totalUsers.count}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-gray-500">Orders (30d)</h3>
<p className="text-3xl font-bold">{totalOrders.count}</p>
</div>
<div className="bg-white rounded-lg shadow p-6 col-span-3">
<h3 className="text-gray-500 mb-4">Recent Orders</h3>
<table className="w-full">
<thead>
<tr>
<th>Order #</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{recentOrders.map(order => (
<tr key={order.id}>
<td>{order.orderNo}</td>
<td>${order.amount}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
File System Access
// app/docs/page.tsx
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';
export default async function DocsPage() {
const docsDir = join(process.cwd(), 'content/docs');
const files = await readdir(docsDir);
const docs = await Promise.all(
files
.filter(f => f.endsWith('.md'))
.map(async (f) => {
const content = await readFile(join(docsDir, f), 'utf-8');
return { filename: f, content };
})
);
return (
<div>
{docs.map(doc => (
<article key={doc.filename}>
<h2>{doc.filename}</h2>
<pre>{doc.content}</pre>
</article>
))}
</div>
);
}
💡 Use the JSON to TypeScript tool to generate TypeScript type definitions from API responses, reducing manual type writing.
Streaming and Suspense Integration
RSC integrates deeply with React Suspense, enabling streaming rendering — page sections load independently, users don't need to wait for all data.
Basic Suspense Pattern
// app/page.tsx
import { Suspense } from 'react';
async function SlowSection() {
const data = await fetch('https://api.example.com/slow', {
next: { revalidate: 60 },
}).then(res => res.json());
return <div>{data.map(item => <p key={item.id}>{item.name}</p>)}</div>;
}
async function FastSection() {
const data = await fetch('https://api.example.com/fast').then(res => res.json());
return <div>{data.message}</div>;
}
export default function HomePage() {
return (
<div>
<h1>Streaming Rendering Example</h1>
<Suspense fallback={<div className="animate-pulse">Fast section loading...</div>}>
<FastSection />
</Suspense>
<Suspense fallback={<div className="animate-pulse">Slow section loading...</div>}>
<SlowSection />
</Suspense>
</div>
);
}
Nested Suspense: Progressive Loading
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function RevenueChart() {
const revenue = await db.query('SELECT * FROM revenue_monthly ORDER BY month');
return <div className="h-64 bg-gray-100 rounded">Chart: {revenue.length} data points</div>;
}
async function UserGrowthChart() {
const growth = await db.query('SELECT * FROM user_growth ORDER BY month');
return <div className="h-64 bg-gray-100 rounded">Growth: {growth.length} data points</div>;
}
async function RecentActivity() {
const activities = await db.query('SELECT * FROM activities ORDER BY created_at DESC LIMIT 20');
return (
<ul>
{activities.map(a => <li key={a.id}>{a.description}</li>)}
</ul>
);
}
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
<UserGrowthChart />
</Suspense>
</div>
<Suspense fallback={<div className="space-y-2">{Array.from({length: 5}).map((_, i) => (
<div key={i} className="h-8 animate-pulse bg-gray-200 rounded" />
))}</div>}>
<RecentActivity />
</Suspense>
</div>
);
}
loading.tsx: Built-in Next.js Suspense
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-2 gap-4">
<div className="h-64 bg-gray-200 rounded" />
<div className="h-64 bg-gray-200 rounded" />
</div>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 bg-gray-200 rounded" />
))}
</div>
</div>
);
}
Next.js App Router and RSC
Next.js App Router is the best practice framework for RSC, providing complete RSC support.
App Router Directory Structure
app/
├── layout.tsx # Root layout (Server Component)
├── page.tsx # Home page (Server Component)
├── loading.tsx # Home page loading state
├── error.tsx # Home page error handling
├── not-found.tsx # 404 page
├── globals.css
├── articles/
│ ├── layout.tsx # Articles layout
│ ├── page.tsx # Article list
│ ├── loading.tsx
│ ├── [id]/
│ │ ├── page.tsx # Article detail
│ │ └── loading.tsx
│ └── new/
│ └── page.tsx # New article
├── api/
│ └── health/
│ └── route.ts # API Route
└── components/
├── Header.tsx
├── LikeButton.tsx # Client Component
└── SearchBar.tsx # Client Component
Root Layout
// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
template: '%s | ToolsKu Blog',
default: 'ToolsKu Blog',
},
description: 'Technical articles and tutorials',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
<a href="/" className="text-xl font-bold text-blue-600">ToolsKu</a>
<div className="flex gap-4">
<a href="/articles" className="text-gray-600 hover:text-blue-600">Articles</a>
<a href="/about" className="text-gray-600 hover:text-blue-600">About</a>
</div>
</div>
</nav>
<main className="max-w-6xl mx-auto px-4 py-8">
{children}
</main>
</body>
</html>
);
}
Dynamic Routes
// app/articles/[id]/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ArticleDetailPage({ params }: PageProps) {
const { id } = await params;
const article = await db.query(
'SELECT * FROM articles WHERE id = $1',
[id]
);
if (!article) {
notFound();
}
return (
<article className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{article.title}</h1>
<div className="prose lg:prose-xl" dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
<div className="mt-8">
<LikeButton articleId={article.id} initialLikes={article.likes} />
</div>
</article>
);
}
Server Actions: Server-Side Mutations
Server Actions are the core mechanism for handling data mutations in the RSC ecosystem, replacing traditional API Routes.
Basic Server Action
// app/articles/new/page.tsx
import { createArticle } from '@/actions/article';
export default function NewArticlePage() {
return (
<form action={createArticle} className="max-w-2xl mx-auto space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
name="title"
type="text"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Content</label>
<textarea
name="content"
required
rows={10}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
>
Publish Article
</button>
</form>
);
}
useActionState: Server Action with State
// app/components/ArticleForm.tsx
'use client';
import { useActionState } from 'react';
import { createArticleWithState } from '@/actions/article';
const initialState = { message: '', errors: {} };
export default function ArticleForm() {
const [state, formAction, isPending] = useActionState(
createArticleWithState,
initialState
);
return (
<form action={formAction} className="space-y-4">
<div>
<input name="title" type="text" placeholder="Title" className="w-full border rounded px-3 py-2" />
{state.errors.title && <p className="text-red-500 text-sm">{state.errors.title}</p>}
</div>
<div>
<textarea name="content" rows={10} placeholder="Content" className="w-full border rounded px-3 py-2" />
{state.errors.content && <p className="text-red-500 text-sm">{state.errors.content}</p>}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
>
{isPending ? 'Publishing...' : 'Publish Article'}
</button>
{state.message && <p className="text-green-600">{state.message}</p>}
</form>
);
}
// actions/article.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createArticleWithState(prevState: any, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const errors: Record<string, string> = {};
if (!title || title.trim().length < 2) {
errors.title = 'Title must be at least 2 characters';
}
if (!content || content.trim().length < 10) {
errors.content = 'Content must be at least 10 characters';
}
if (Object.keys(errors).length > 0) {
return { message: '', errors };
}
await db.query(
'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
[title, content]
);
revalidatePath('/articles');
return { message: 'Article published successfully!', errors: {} };
}
Optimistic Updates: useOptimistic
// app/components/OptimisticLikeButton.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from '@/actions/article';
interface LikeButtonProps {
articleId: number;
initialLikes: number;
isLiked: boolean;
}
export default function OptimisticLikeButton({ articleId, initialLikes, isLiked }: LikeButtonProps) {
const [optimisticState, addOptimistic] = useOptimistic(
{ likes: initialLikes, isLiked },
(state, newIsLiked: boolean) => ({
likes: newIsLiked ? state.likes + 1 : state.likes - 1,
isLiked: newIsLiked,
})
);
const handleToggle = async () => {
const newIsLiked = !optimisticState.isLiked;
addOptimistic(newIsLiked);
await toggleLike(articleId, newIsLiked);
};
return (
<button
onClick={handleToggle}
className={`px-4 py-2 rounded ${optimisticState.isLiked ? 'bg-red-500 text-white' : 'bg-gray-200'}`}
>
{optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
</button>
);
}
Caching Strategies: revalidate and ISR
RSC caching strategies determine the balance between data freshness and performance.
Caching Strategy Comparison
| Strategy | Function | Cache Duration | Use Case |
|---|---|---|---|
| Static rendering | Default | Build-time, permanent cache | Blogs, docs |
| ISR | revalidate |
Periodic revalidation | News, product listings |
| Dynamic rendering | no-store |
Re-fetch every request | Real-time data, user dashboards |
| On-demand revalidation | revalidatePath/Tag |
Manual trigger | After content updates |
fetch Cache Configuration
// Static rendering: fetch at build time, permanent cache
const staticData = await fetch('https://api.example.com/docs', {
cache: 'force-cache',
});
// ISR: revalidate every 60 seconds
const isrData = await fetch('https://api.example.com/articles', {
next: { revalidate: 60 },
});
// Dynamic rendering: re-fetch every request
const dynamicData = await fetch('https://api.example.com/realtime', {
cache: 'no-store',
});
// Tag-based cache, can be cleared on demand
const taggedData = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});
On-Demand Revalidation
// actions/revalidate.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function revalidateArticles() {
revalidatePath('/articles');
revalidatePath('/articles/[id]', 'page');
}
export async function revalidateProducts() {
revalidateTag('products');
}
Route Segment Configuration
// app/articles/page.tsx
export const revalidate = 300; // ISR: revalidate every 5 minutes
export default async function ArticlesPage() {
const articles = await fetch('https://api.example.com/articles', {
next: { revalidate: 300 },
}).then(res => res.json());
return <div>{articles.map(a => <p key={a.id}>{a.title}</p>)}</div>;
}
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // Force dynamic rendering
export default async function DashboardPage() {
const data = await db.query('SELECT * FROM stats');
return <div>{data.length} records</div>;
}
💡 Use the Base64 Encode tool for API Token and cache Key encoding needs.
Performance Comparison: Bundle Size, TTFB, FCP
RSC shows significant improvements in performance metrics. Here are real project comparison data.
Bundle Size Comparison
| Metric | Pages Router (SSR) | App Router (RSC) | Improvement |
|---|---|---|---|
| Home JS size | 187 KB | 42 KB | -77% |
| Article JS size | 203 KB | 38 KB | -81% |
| Third-party lib size | 94 KB | 0 KB (server) | -100% |
| Hydration script | 156 KB | 23 KB | -85% |
Core Web Vitals Comparison
| Metric | Pages Router | App Router (RSC) | Improvement |
|---|---|---|---|
| TTFB | 320ms | 180ms | -44% |
| FCP | 1.8s | 0.9s | -50% |
| LCP | 2.5s | 1.4s | -44% |
| TTI | 3.2s | 1.6s | -50% |
| CLS | 0.12 | 0.05 | -58% |
Why Is RSC Faster?
- Zero Hydration: Server Components don't need hydration, reducing JS execution time
- Streaming: Page chunks arrive progressively, FCP significantly earlier
- Deps not in bundle:
marked,highlight.jsetc. only run on server - Fewer request waterfalls: Server-side parallel data fetching
Performance Measurement Code
// app/api/metrics/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const metrics = {
bundleSizeKB: 42,
ttfbMs: 180,
fcpMs: 900,
lcpMs: 1400,
cls: 0.05,
timestamp: new Date().toISOString(),
};
return NextResponse.json(metrics);
}
Common Pitfalls and Errors
Pitfall 1: Using Client APIs in Server Components
// ❌ Wrong: Server Component cannot use useState
export default async function Page() {
const [count, setCount] = useState(0); // Error!
return <div>{count}</div>;
}
// ✅ Correct: Extract to Client Component
// components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Pitfall 2: Client Component Importing Server Component
// ❌ Wrong: Client component cannot directly import Server component
'use client';
import ServerDataTable from './ServerDataTable'; // Error!
export default function ClientWrapper() {
return <ServerDataTable />;
}
// ✅ Correct: Pass via children prop
// components/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
);
}
// page.tsx
import ClientWrapper from '@/components/ClientWrapper';
import ServerDataTable from '@/components/ServerDataTable';
export default function Page() {
return (
<ClientWrapper>
<ServerDataTable />
</ClientWrapper>
);
}
Pitfall 3: Forgetting revalidate in Server Actions
// ❌ Wrong: Cache not updated after data mutation
'use server';
export async function updateArticle(id: number, data: FormData) {
await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
// Missing revalidatePath! Users still see old data
}
// ✅ Correct: Revalidate after mutation
'use server';
import { revalidatePath } from 'next/cache';
export async function updateArticle(id: number, data: FormData) {
await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
revalidatePath('/articles');
revalidatePath(`/articles/${id}`);
}
Pitfall 4: Serializing Non-Serializable Data
// ❌ Wrong: Passing non-serializable props to Client Component
import LikeButton from './LikeButton';
export default async function Page() {
const result = await db.query('SELECT * FROM articles');
// Date objects cannot be serialized to Client Components
return <LikeButton createdAt={result.createdAt} />;
}
// ✅ Correct: Convert to serializable values
export default async function Page() {
const result = await db.query('SELECT * FROM articles');
return <LikeButton createdAt={result.createdAt.toISOString()} />;
}
Common Error Quick Reference
| Error Message | Cause | Solution |
|---|---|---|
useState is not defined |
Using Hooks in Server Component | Add "use client" or extract to Client Component |
onClick is not defined |
Using events in Server Component | Extract interactive part to Client Component |
Functions cannot be passed as props |
Passing function to Client Component | Use Server Actions |
Only plain objects can be passed |
Passing non-serializable data | Convert to JSON-compatible format |
Hydration mismatch |
Server/Client render inconsistency | Check dynamic content consistency |
Migration from Pages Router
Migration Steps Overview
| Step | Content | Estimated Time |
|---|---|---|
| 1 | Create app/ directory, keep pages/ |
1 hour |
| 2 | Migrate root layout _app.tsx → app/layout.tsx |
2-4 hours |
| 3 | Migrate pages pages/ → app/ one by one |
1-2 hours per page |
| 4 | Migrate getServerSideProps → Server Component |
1-2 hours per page |
| 5 | Migrate getStaticProps → Static rendering + ISR |
1-2 hours per page |
| 6 | Migrate API Routes → Server Actions / Route Handlers | 0.5-1 hour each |
| 7 | Remove pages/ directory |
0.5 hour |
getServerSideProps Migration
// Pages Router: getServerSideProps
export async function getServerSideProps({ params }) {
const article = await db.article.findUnique({ where: { id: params.id } });
return { props: { article } };
}
export default function ArticlePage({ article }) {
return <div>{article.title}</div>;
}
// App Router: Server Component
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
if (!article) {
notFound();
}
return <div>{article.title}</div>;
}
getStaticProps Migration
// Pages Router: getStaticProps + ISR
export async function getStaticPaths() {
const articles = await db.article.findMany({ select: { id: true } });
return {
paths: articles.map(a => ({ params: { id: String(a.id) } })),
fallback: 'blocking',
};
}
export async function getStaticProps({ params }) {
const article = await db.article.findUnique({ where: { id: params.id } });
return { props: { article }, revalidate: 300 };
}
// App Router: generateStaticParams + ISR
export async function generateStaticParams() {
const articles = await db.article.findMany({ select: { id: true } });
return articles.map(a => ({ id: String(a.id) }));
}
export const revalidate = 300;
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
return <div>{article.title}</div>;
}
API Routes Migration
// Pages Router: API Route
// pages/api/articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const articles = await db.article.findMany();
res.status(200).json(articles);
} else if (req.method === 'POST') {
const { title, content } = req.body;
const article = await db.article.create({ data: { title, content } });
res.status(201).json(article);
}
}
// App Router: Route Handler
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET() {
const articles = await db.article.findMany();
return NextResponse.json(articles);
}
export async function POST(request: NextRequest) {
const { title, content } = await request.json();
const article = await db.article.create({ data: { title, content } });
return NextResponse.json(article, { status: 201 });
}
SEO Optimization with RSC
RSC is inherently SEO-friendly — Server Components render on the server, search engines can directly crawl complete HTML.
Metadata API
// app/articles/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
return {
title: article.title,
description: article.excerpt,
openGraph: {
title: article.title,
description: article.excerpt,
type: 'article',
publishedTime: article.createdAt.toISOString(),
authors: [article.author.name],
images: [{ url: article.coverImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.excerpt,
images: [article.coverImage],
},
};
}
Sitemap and Robots
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await db.article.findMany({
select: { id: true, updatedAt: true },
});
const articleUrls = articles.map(article => ({
url: `https://toolsku.com/articles/${article.id}`,
lastModified: article.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{ url: 'https://toolsku.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: 'https://toolsku.com/articles', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...articleUrls,
];
}
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] },
],
sitemap: 'https://toolsku.com/sitemap.xml',
};
}
Structured Data (JSON-LD)
// app/articles/[id]/page.tsx
export default async function ArticlePage({ params }: PageProps) {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
image: article.coverImage,
datePublished: article.createdAt.toISOString(),
dateModified: article.updatedAt.toISOString(),
author: {
'@type': 'Person',
name: article.author.name,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
</article>
</>
);
}
FAQ
What's the difference between RSC and SSR?
SSR renders HTML on the server but still requires Hydration on the client (loading full JS to rebuild the component tree). Server Components rendered by RSC do not need Hydration — their code is never sent to the client.
Should all components use Server Components?
Default to Server Components, only use Client Components when interactivity is needed (useState, useEffect, event handling). Rule of thumb: 80% Server + 20% Client.
Can I use RSC without Next.js?
Theoretically yes — React provides the underlying protocol, but you need to implement the RSC transport and rendering pipeline yourself. Currently, Next.js App Router is the most mature RSC implementation. Other frameworks (Remix, Waku) are also catching up.
Are Server Actions secure?
Server Actions are essentially POST requests, and Next.js automatically provides CSRF protection. However, you still need to do input validation and permission checks inside Actions — never trust any data from the client.
Does RSC help with SEO?
Absolutely. Server Components render complete HTML on the server, which search engines can directly crawl. Combined with Metadata API, Sitemap, and JSON-LD, SEO performance far exceeds client-side rendering.
How to debug Server Components?
- Use
console.login Server Components — output appears in the server terminal - Use Next.js Dev Tools to view component rendering boundaries
- Use React DevTools to view the Client Component tree
- Enable
logging: trueinnext.config.js
Recommended migration order for large projects?
- Start with pure display pages (blogs, docs) — lowest risk
- Then migrate data display pages (lists, details) — replace getServerSideProps
- Finally migrate interactive pages (forms, dashboards) — need Server Actions
- Keep
pages/andapp/coexisting, migrate gradually
Try these browser-local tools — no sign-up required →