React Server Components in Next.js
React Server Components (RSC) are a new component paradigm where components run exclusively on the server. They can directly access databases, file systems, and backend services without creating API endpoints. Their JavaScript is never sent to the client โ only the rendered output is streamed as a special RSC payload.
In Next.js, every component inside the app/ directory is a Server Component by default. You do not need to add any special directive to make a component server-only. To opt a component into client-side rendering, add 'use client' at the top of the file.
info
Understanding the difference between Server and Client Components is the most important concept in the App Router. Here is a comparison:
| Feature | Server Component | Client Component |
|---|---|---|
| Runs on | Server only | Server (SSR) + Client (hydration) |
| Directive | None (default) | 'use client' |
| Bundle size impact | Zero โ not in client bundle | Included in client bundle |
| useState / useEffect | Cannot use | Can use |
| Event handlers | Cannot use | Can use |
| Browser APIs | Cannot access | Can access |
| Direct DB access | Can access | Cannot access |
| async / await | Can use directly | Cannot use directly (use in Server Components) |
| 1 | // app/components/server-component.tsx |
| 2 | // This is a Server Component (no directive needed) |
| 3 | |
| 4 | import { db } from "@/lib/database"; |
| 5 | |
| 6 | export default async function UserProfile({ userId }: { userId: string }) { |
| 7 | // Direct database access โ no API route needed |
| 8 | const user = await db.user.findUnique({ where: { id: userId } }); |
| 9 | |
| 10 | // This JavaScript is NEVER sent to the client |
| 11 | return ( |
| 12 | <div className="p-4 border rounded"> |
| 13 | <h2>{user.name}</h2> |
| 14 | <p>{user.email}</p> |
| 15 | </div> |
| 16 | ); |
| 17 | } |
| 1 | // app/components/client-counter.tsx |
| 2 | "use client"; // Opt into client-side rendering |
| 3 | |
| 4 | import { useState } from "react"; |
| 5 | |
| 6 | export default function Counter() { |
| 7 | const [count, setCount] = useState(0); |
| 8 | |
| 9 | // This component is sent to the client as JavaScript |
| 10 | // It hydrates on the client to become interactive |
| 11 | return ( |
| 12 | <div className="p-4 border rounded"> |
| 13 | <p className="text-sm mb-2">Count: {count}</p> |
| 14 | <div className="flex gap-2"> |
| 15 | <button |
| 16 | onClick={() => setCount(c => c - 1)} |
| 17 | className="px-3 py-1 bg-gray-700 rounded text-sm" |
| 18 | > |
| 19 | - |
| 20 | </button> |
| 21 | <button |
| 22 | onClick={() => setCount(c => c + 1)} |
| 23 | className="px-3 py-1 bg-[#00FF41] text-black rounded text-sm" |
| 24 | > |
| 25 | + |
| 26 | </button> |
| 27 | </div> |
| 28 | </div> |
| 29 | ); |
| 30 | } |
warning
The key to effective RSC usage is composition. Server Components can render Client Components, and Client Components can import Server Components as children or via the children prop. But Client Components cannot import Server Components directly โ they must receive them as props.
Pattern 1: Server renders Client
A Server Component imports and renders a Client Component. The Server Component can fetch data and pass it as props.
| 1 | // app/dashboard/page.tsx (Server Component) |
| 2 | import { db } from "@/lib/database"; |
| 3 | import { LikeButton } from "./like-button"; // Client |
| 4 | |
| 5 | export default async function DashboardPage() { |
| 6 | const posts = await db.post.findMany(); |
| 7 | |
| 8 | return ( |
| 9 | <div> |
| 10 | <h1>Dashboard</h1> |
| 11 | {posts.map(post => ( |
| 12 | <article key={post.id}> |
| 13 | <h2>{post.title}</h2> |
| 14 | <p>{post.content}</p> |
| 15 | {/* Server passes data to Client Component */} |
| 16 | <LikeButton postId={post.id} initialLikes={post.likes} /> |
| 17 | </article> |
| 18 | ))} |
| 19 | </div> |
| 20 | ); |
| 21 | } |
| 1 | // app/dashboard/like-button.tsx (Client Component) |
| 2 | "use client"; |
| 3 | |
| 4 | import { useState } from "react"; |
| 5 | |
| 6 | export function LikeButton({ |
| 7 | postId, |
| 8 | initialLikes, |
| 9 | }: { |
| 10 | postId: string; |
| 11 | initialLikes: number; |
| 12 | }) { |
| 13 | const [likes, setLikes] = useState(initialLikes); |
| 14 | |
| 15 | async function handleLike() { |
| 16 | setLikes(l => l + 1); |
| 17 | await fetch(`/api/posts/${postId}/like`, { method: "POST" }); |
| 18 | } |
| 19 | |
| 20 | return ( |
| 21 | <button onClick={handleLike} className="text-sm text-gray-400"> |
| 22 | โฅ {likes} |
| 23 | </button> |
| 24 | ); |
| 25 | } |
Pattern 2: Server passes children to Client
A Client Component receives Server Components as children. This allows you to keep the interactive wrapper client-side while the content is server-rendered.
| 1 | // app/components/tabs.tsx (Client Component) |
| 2 | "use client"; |
| 3 | |
| 4 | import { useState } from "react"; |
| 5 | |
| 6 | export function Tabs({ |
| 7 | items, |
| 8 | }: { |
| 9 | items: { label: string; content: React.ReactNode }[]; |
| 10 | }) { |
| 11 | const [active, setActive] = useState(0); |
| 12 | |
| 13 | return ( |
| 14 | <div> |
| 15 | <div className="flex gap-4 border-b mb-4"> |
| 16 | {items.map((item, i) => ( |
| 17 | <button |
| 18 | key={item.label} |
| 19 | onClick={() => setActive(i)} |
| 20 | className={i === active ? "border-b-2 border-[#00FF41]" : ""} |
| 21 | > |
| 22 | {item.label} |
| 23 | </button> |
| 24 | ))} |
| 25 | </div> |
| 26 | <div>{items[active].content}</div> |
| 27 | </div> |
| 28 | ); |
| 29 | } |
| 1 | // app/dashboard/page.tsx (Server Component) |
| 2 | import { Tabs } from "@/components/tabs"; |
| 3 | import { OverviewTab } from "./overview-tab"; // Server |
| 4 | import { AnalyticsTab } from "./analytics-tab"; // Server |
| 5 | import { SettingsTab } from "./settings-tab"; // Server |
| 6 | |
| 7 | export default async function DashboardPage() { |
| 8 | return ( |
| 9 | <Tabs |
| 10 | items={[ |
| 11 | { label: "Overview", content: <OverviewTab /> }, |
| 12 | { label: "Analytics", content: <AnalyticsTab /> }, |
| 13 | { label: "Settings", content: <SettingsTab /> }, |
| 14 | ]} |
| 15 | /> |
| 16 | ); |
| 17 | } |
pro tip
Pattern 3: Separating Client Islands
Keep Client Components as small and focused as possible. A page can be mostly Server Components with tiny Client Component islands for interactive elements.
| 1 | // app/products/page.tsx (Server Component) |
| 2 | import { db } from "@/lib/database"; |
| 3 | import { ProductCard } from "./product-card"; // Server |
| 4 | import { AddToCartButton } from "./add-to-cart"; // Client |
| 5 | import { ProductFilters } from "./filters"; // Client |
| 6 | import { ProductGrid } from "./product-grid"; // Server |
| 7 | |
| 8 | export default async function ProductsPage({ |
| 9 | searchParams, |
| 10 | }: { |
| 11 | searchParams: Promise<{ category?: string }>; |
| 12 | }) { |
| 13 | const { category } = await searchParams; |
| 14 | const products = await db.product.findMany({ |
| 15 | where: category ? { category } : undefined, |
| 16 | }); |
| 17 | |
| 18 | return ( |
| 19 | <div className="flex gap-6"> |
| 20 | {/* Client island โ handles filter interactions */} |
| 21 | <aside className="w-64"> |
| 22 | <ProductFilters selected={category} /> |
| 23 | </aside> |
| 24 | |
| 25 | {/* Server-rendered grid */} |
| 26 | <ProductGrid> |
| 27 | {products.map(product => ( |
| 28 | <ProductCard key={product.id} product={product}> |
| 29 | {/* Tiny client island โ just the button */} |
| 30 | <AddToCartButton productId={product.id} /> |
| 31 | </ProductCard> |
| 32 | ))} |
| 33 | </ProductGrid> |
| 34 | </div> |
| 35 | ); |
| 36 | } |
Streaming allows the server to send HTML progressively. Instead of waiting for all data to load before sending anything, the server sends the shell of the page immediately and streams in slow data as it resolves. This dramatically improves Time to First Byte (TTFB).
| 1 | // app/dashboard/page.tsx |
| 2 | import { Suspense } from "react"; |
| 3 | import { SlowAnalytics } from "./slow-analytics"; |
| 4 | import { FastStats } from "./fast-stats"; |
| 5 | import { RecentActivity } from "./recent-activity"; |
| 6 | |
| 7 | export default function DashboardPage() { |
| 8 | return ( |
| 9 | <div> |
| 10 | {/* This renders immediately */} |
| 11 | <h1>Dashboard</h1> |
| 12 | |
| 13 | {/* Fast data โ renders in the shell */} |
| 14 | <FastStats /> |
| 15 | |
| 16 | {/* Slow data โ streams in when ready */} |
| 17 | <Suspense fallback={<div className="h-64 bg-gray-800 animate-pulse rounded" />}> |
| 18 | <SlowAnalytics /> |
| 19 | </Suspense> |
| 20 | |
| 21 | {/* Another slow section โ streams independently */} |
| 22 | <Suspense fallback={<div className="h-32 bg-gray-800 animate-pulse rounded" />}> |
| 23 | <RecentActivity /> |
| 24 | </Suspense> |
| 25 | </div> |
| 26 | ); |
| 27 | } |
| 1 | // app/dashboard/slow-analytics.tsx (Server Component) |
| 2 | import { db } from "@/lib/database"; |
| 3 | |
| 4 | export default async function SlowAnalytics() { |
| 5 | // This takes 3 seconds to resolve |
| 6 | const analytics = await db.analytics.aggregate({ |
| 7 | period: "30d", |
| 8 | delay: 3000, // Simulated slow query |
| 9 | }); |
| 10 | |
| 11 | return ( |
| 12 | <div className="grid grid-cols-3 gap-4 p-4 border rounded"> |
| 13 | <div> |
| 14 | <p className="text-sm text-gray-500">Total Views</p> |
| 15 | <p className="text-2xl font-mono">{analytics.totalViews}</p> |
| 16 | </div> |
| 17 | <div> |
| 18 | <p className="text-sm text-gray-500">Unique Visitors</p> |
| 19 | <p className="text-2xl font-mono">{analytics.uniqueVisitors}</p> |
| 20 | </div> |
| 21 | <div> |
| 22 | <p className="text-sm text-gray-500">Bounce Rate</p> |
| 23 | <p className="text-2xl font-mono">{analytics.bounceRate}%</p> |
| 24 | </div> |
| 25 | </div> |
| 26 | ); |
| 27 | } |
info
Server Components can use async/await directly to fetch data. Combined with caching and Suspense, this provides a powerful data fetching model.
| 1 | // app/blog/page.tsx |
| 2 | import { db } from "@/lib/database"; |
| 3 | |
| 4 | // This fetch is deduplicated per request |
| 5 | async function getPosts() { |
| 6 | const posts = await db.post.findMany({ |
| 7 | orderBy: { createdAt: "desc" }, |
| 8 | include: { author: true, tags: true }, |
| 9 | }); |
| 10 | return posts; |
| 11 | } |
| 12 | |
| 13 | // This fetch is also deduplicated โ same request |
| 14 | async function getFeaturedPost() { |
| 15 | const post = await db.post.findFirst({ |
| 16 | where: { featured: true }, |
| 17 | include: { author: true }, |
| 18 | }); |
| 19 | return post; |
| 20 | } |
| 21 | |
| 22 | export default async function BlogPage() { |
| 23 | // Both fetches run in parallel |
| 24 | const [posts, featured] = await Promise.all([ |
| 25 | getPosts(), |
| 26 | getFeaturedPost(), |
| 27 | ]); |
| 28 | |
| 29 | return ( |
| 30 | <div> |
| 31 | <h1>Blog</h1> |
| 32 | {featured && <FeaturedCard post={featured} />} |
| 33 | <div className="grid grid-cols-2 gap-4"> |
| 34 | {posts.map(post => ( |
| 35 | <PostCard key={post.id} post={post} /> |
| 36 | ))} |
| 37 | </div> |
| 38 | </div> |
| 39 | ); |
| 40 | } |
Request Deduplication
When multiple Server Components make the same fetch request during a single render, Next.js automatically deduplicates the requests. This means if two components both call fetch("/api/data"), only one HTTP request is made. This eliminates the waterfall problem common in client-side fetching.
Add 'use client' only when your component needs one or more of these:
| 1 | // โ Good: Server Component with data fetching |
| 2 | async function ProductList() { |
| 3 | const products = await db.product.findMany(); |
| 4 | return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>; |
| 5 | } |
| 6 | |
| 7 | // โ Good: Tiny Client Component for interactivity |
| 8 | "use client"; |
| 9 | function AddToCartButton({ productId }: { productId: string }) { |
| 10 | const [loading, setLoading] = useState(false); |
| 11 | return ( |
| 12 | <button onClick={() => { setLoading(true); addToCart(productId); }}> |
| 13 | {loading ? "Adding..." : "Add to Cart"} |
| 14 | </button> |
| 15 | ); |
| 16 | } |
| 17 | |
| 18 | // โ Bad: Entire page marked as Client Component |
| 19 | "use client"; |
| 20 | export default function ProductsPage() { |
| 21 | // Now the ENTIRE page + all children are client components |
| 22 | const products = useFetch("/api/products"); // Unnecessary |
| 23 | return <ul>{products.map(p => <li>{p.name}</li>)}</ul>; |
| 24 | } |
best practice
When passing props from Server Components to Client Components, the data must be serializable. This means it can be converted to JSON. Functions, classes, Dates, Maps, and Sets cannot cross the server-client boundary.
| Type | Serializable | Workaround |
|---|---|---|
| Strings, numbers, booleans | Yes | โ |
| Plain objects, arrays | Yes | โ |
| null, undefined | Yes | โ |
| Date objects | No | Pass ISO string, parse in client |
| Functions | No | Define in client component or pass action reference |
| Classes | No | Convert to plain object |
| Map, Set, WeakMap | No | Convert to Array or Object |
| 1 | // Server Component |
| 2 | async function PostPage({ params }: { params: Promise<{ id: string }> }) { |
| 3 | const { id } = await params; |
| 4 | const post = await getPost(id); |
| 5 | |
| 6 | // โ Pass serializable data to client |
| 7 | <PostActions |
| 8 | postId={post.id} |
| 9 | title={post.title} |
| 10 | createdAt={post.createdAt.toISOString()} // Date โ string |
| 11 | />; |
| 12 | } |
Server Components provide significant performance improvements over traditional client-side React:
Zero Client Bundle for Server Components
Server Component code is never included in the client bundle. A page with complex data transformation logic, markdown parsing, or syntax highlighting adds zero bytes to the client if those components are server-only.
Reduced Waterfalls
Server-side data fetching with automatic deduplication eliminates the client waterfall. Instead of fetch โ render โ fetch โ render on the client, the server resolves all data dependencies before sending the first byte.
Streaming for Perceived Performance
With Suspense, the shell of the page streams instantly while slow data loads. Users see content immediately instead of a loading spinner.
Smaller Hydration Payload
Only Client Components hydrate on the client. With Server Components handling most of the UI, the hydration payload is significantly smaller, leading to faster Time to Interactive (TTI).
info
โ Adding 'use client' to every component
This defeats the purpose of RSC entirely. Start with Server Components and only add the directive where needed.
โ Trying to use hooks in Server Components
useState, useEffect, and other hooks only work in Client Components. If you need state, create a dedicated Client Component.
โ Passing non-serializable props to Client Components
Functions, Date objects, and class instances cannot cross the server-client boundary. Convert them to serializable formats first.
โ Creating unnecessary Client Component wrappers
Do not create a Client Component that just wraps a Server Component. Pass the Server Component as children instead.
best practice