React Server Components
Build performant applications with React Server Components and Next.js App Router.
Core Workflow Understand RSC model: Server vs client components Design component tree: Plan server/client boundaries Implement data fetching: Fetch in server components Add interactivity: Client components where needed Enable streaming: Suspense for progressive loading Optimize: Minimize client bundle size Server vs Client Components Key Differences Feature Server Components Client Components Rendering Server only Server + Client Bundle size Zero JS sent Adds to bundle Data fetching Direct DB/API access useEffect/TanStack Query State/Effects No hooks Full React hooks Event handlers No Yes Browser APIs No Yes File directive Default (none) 'use client' When to Use Each
Server Components (Default)
Static content Data fetching Backend resource access Large dependencies (markdown, syntax highlighting) Sensitive logic/tokens
Client Components
Interactive UI (onClick, onChange) useState, useEffect, useReducer Browser APIs (localStorage, geolocation) Custom hooks with state Third-party client libraries Basic Patterns Server Component (Default) // app/users/page.tsx (Server Component - no directive needed) import { db } from '@/lib/db';
async function getUsers() { return db.user.findMany({ orderBy: { createdAt: 'desc' }, }); }
export default async function UsersPage() { const users = await getUsers();
return (
Users
-
{users.map((user) => (
- {user.name} ))}
Client Component // components/Counter.tsx 'use client';
import { useState } from 'react';
export function Counter() { const [count, setCount] = useState(0);
return ( ); }
Composition Pattern // app/dashboard/page.tsx (Server Component) import { db } from '@/lib/db'; import { DashboardClient } from './DashboardClient'; import { StatsCard } from '@/components/StatsCard';
async function getStats() { const [users, orders, revenue] = await Promise.all([ db.user.count(), db.order.count(), db.order.aggregate({ _sum: { total: true } }), ]); return { users, orders, revenue: revenue._sum.total }; }
export default async function DashboardPage() { const stats = await getStats();
return (
Dashboard
{/* Server components with data */}
<div className="grid grid-cols-3 gap-4">
<StatsCard title="Users" value={stats.users} />
<StatsCard title="Orders" value={stats.orders} />
<StatsCard title="Revenue" value={`$${stats.revenue}`} />
</div>
{/* Client component for interactivity */}
<DashboardClient initialData={stats} />
</div>
); }
// app/dashboard/DashboardClient.tsx 'use client';
import { useState } from 'react'; import { DateRangePicker } from '@/components/DateRangePicker'; import { Chart } from '@/components/Chart';
interface DashboardClientProps { initialData: Stats; }
export function DashboardClient({ initialData }: DashboardClientProps) { const [dateRange, setDateRange] = useState({ from: null, to: null });
return (
Data Fetching Patterns
Parallel Data Fetching
// app/page.tsx
async function getUser(id: string) {
const res = await fetch(/api/users/${id});
return res.json();
}
async function getPosts(userId: string) {
const res = await fetch(/api/users/${userId}/posts);
return res.json();
}
async function getComments(userId: string) {
const res = await fetch(/api/users/${userId}/comments);
return res.json();
}
export default async function ProfilePage({ params }: { params: { id: string } }) { // Parallel fetching - all requests start simultaneously const [user, posts, comments] = await Promise.all([ getUser(params.id), getPosts(params.id), getComments(params.id), ]);
return (
Sequential Data Fetching (When Dependent) export default async function OrderPage({ params }: { params: { id: string } }) { // Sequential - second fetch depends on first const order = await getOrder(params.id); const customer = await getCustomer(order.customerId);
return (
Caching and Revalidation // Static data (cached indefinitely) async function getStaticData() { const res = await fetch('https://api.example.com/static', { cache: 'force-cache', // Default }); return res.json(); }
// Dynamic data (no cache) async function getDynamicData() { const res = await fetch('https://api.example.com/dynamic', { cache: 'no-store', }); return res.json(); }
// Revalidate after time async function getRevalidatedData() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 3600 }, // Revalidate every hour }); return res.json(); }
// Revalidate by tag async function getTaggedData() { const res = await fetch('https://api.example.com/products', { next: { tags: ['products'] }, }); return res.json(); }
// Trigger revalidation import { revalidateTag } from 'next/cache'; revalidateTag('products');
Streaming with Suspense Page-Level Streaming // app/dashboard/page.tsx import { Suspense } from 'react'; import { StatsCards, StatsCardsSkeleton } from './StatsCards'; import { RecentOrders, RecentOrdersSkeleton } from './RecentOrders'; import { TopProducts, TopProductsSkeleton } from './TopProducts';
export default function DashboardPage() { return (
Dashboard
{/* Each section streams independently */}
<Suspense fallback={<StatsCardsSkeleton />}>
<StatsCards />
</Suspense>
<div className="grid grid-cols-2 gap-4 mt-8">
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<TopProductsSkeleton />}>
<TopProducts />
</Suspense>
</div>
</div>
); }
Component with Data // app/dashboard/StatsCards.tsx import { db } from '@/lib/db';
async function getStats() { // Simulate slow query await new Promise((resolve) => setTimeout(resolve, 2000)); return db.stats.findFirst(); }
export async function StatsCards() { const stats = await getStats();
return (
export function StatsCardsSkeleton() { return (
Loading States // app/products/loading.tsx export default function ProductsLoading() { return (
Server Actions Form Actions // app/contact/page.tsx import { submitContact } from './actions';
export default function ContactPage() { return (
); }// app/contact/actions.ts 'use server';
import { z } from 'zod'; import { revalidatePath } from 'next/cache';
const schema = z.object({ email: z.string().email(), message: z.string().min(10), });
export async function submitContact(formData: FormData) { const validatedFields = schema.safeParse({ email: formData.get('email'), message: formData.get('message'), });
if (!validatedFields.success) { return { error: validatedFields.error.flatten().fieldErrors }; }
await db.contact.create({ data: validatedFields.data, });
revalidatePath('/contacts'); return { success: true }; }
With useActionState // components/ContactForm.tsx 'use client';
import { useActionState } from 'react'; import { submitContact } from '@/app/contact/actions';
export function ContactForm() { const [state, formAction, isPending] = useActionState(submitContact, null);
return (