React Server Components
React Server Components (RSC) let you render components entirely on the server, sending finished HTML and a compact serialized payload to the browser instead of shipping the component's JavaScript. This is a genuine architectural shift: data fetching happens where your data lives, secrets never reach the client, and your bundle shrinks because server-only code stays on the server. In the Next.js App Router every component under app/ is a Server Component by default, and you opt into the client only where interactivity demands it. Getting this boundary right is the single most impactful skill for building fast, modern React apps.
- Distinguish Server Components from Client Components and what each can do
- Fetch data directly in async Server Components without useEffect
- Place the 'use client' boundary deliberately to minimize bundle size
- Stream UI progressively with Suspense and loading.tsx
- Mutate data safely with Server Actions
The Two-Component Model
Every component in the App Router is one of two kinds. Server Components run only during rendering on the server (or at build time) and never re-run in the browser — so they can be async, read from a database, and use secrets, but they cannot use state, effects, or event handlers. Client Components run in the browser after hydration and can do all the interactive things, but they cannot be async and their code ships to the user.
tsx// app/posts/page.tsx — Server Component (default, no directive needed) import { db } from "@/lib/db" export default async function PostsPage() { const posts = await db.post.findMany({ orderBy: { createdAt: "desc" } }) return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ) }
Quick decision rule
useState/useReducer, useEffect, an event handler like onClick, or a browser API such as localStorage, window, or IntersectionObserver. Everything else should stay a Server Component.Fetching Data on the Server
Because Server Components can be async, you await your data directly in the component body — no useEffect, no loading flags, no client-side waterfall. Next.js extends the native fetch API with caching and revalidation controls, so you can turn a request into cached, static, or revalidating data with a single option.
tsx// app/products/[id]/page.tsx export default async function ProductPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const res = await fetch(`https://api.shop.com/products/${id}`, { // Re-fetch at most once every 60 seconds (incremental static regeneration) next: { revalidate: 60 }, }) if (!res.ok) throw new Error("Failed to load product") const product = await res.json() return ( <article> <h1>{product.name}</h1> <p>${product.price}</p> </article> ) }
Caching options
cache: "force-cache" for data that rarely changes, next: { revalidate: N } for time-based refresh, and cache: "no-store" for per-request data. Pair next: { tags: ["products"] } with revalidateTag("products") in a Server Action for precise on-demand invalidation.The use client Boundary
The "use client" directive does not just mark one file — it marks the entry point into the client bundle. Every module imported (directly or transitively) by a Client Component is bundled and shipped to the browser. The goal is therefore to push the directive down the tree: keep large, data-heavy layouts on the server and make only the small interactive leaves client-side.
tsx// app/dashboard/page.tsx — stays on the server import { Chart } from "@/components/chart" // Client Component (interactive) import { getMetrics } from "@/lib/metrics" export default async function Dashboard() { const metrics = await getMetrics() // heavy query, runs on server only // Only <Chart> and its deps ship to the browser — not this page or getMetrics return <Chart data={metrics} /> }
Warning
"use client" at the top of a layout or page — you accidentally opt the entire subtree out of server rendering and bloat the bundle.Composing Server and Client Components
A Client Component cannot import a Server Component (the server code would have no way to run in the browser). The escape hatch is composition: pass Server Components into a Client Component through the children prop or any other prop that holds JSX. React renders the server content on the server and hands the Client Component a ready-made element.
tsx// components/collapsible.tsx "use client" import { useState, type ReactNode } from "react" export function Collapsible({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false) return ( <section> <button onClick={() => setOpen((o) => !o)}>Toggle</button> {open && children} </section> ) }
tsx// app/page.tsx — Server Component composes the two import { Collapsible } from "@/components/collapsible" import { ServerReport } from "@/components/server-report" // async Server Component export default function Page() { return ( <Collapsible> <ServerReport /> {/* rendered on the server, slotted in as children */} </Collapsible> ) }
Props must be serializable
Date, Map, and Set work; functions (other than Server Actions) and class instances do not.Streaming with Suspense
Server rendering does not have to be all-or-nothing. Wrap a slow async Server Component in <Suspense> and Next.js streams the rest of the page immediately, then flushes the slow part in when its data resolves. This keeps time-to-first-byte low and lets fast content paint without waiting on the slowest query.
tsx// app/feed/page.tsx import { Suspense } from "react" import { Timeline } from "@/components/timeline" // slow async component import { Sidebar } from "@/components/sidebar" // fast export default function FeedPage() { return ( <div className="grid grid-cols-[1fr_320px]"> <Suspense fallback={<TimelineSkeleton />}> <Timeline /> {/* streams in when ready */} </Suspense> <Sidebar /> {/* renders instantly */} </div> ) }
A loading.tsx file in any route segment is a shortcut: Next.js automatically wraps that segment's page.tsx in a Suspense boundary and shows loading.tsx as the fallback during navigation and initial load.
tsx// app/feed/loading.tsx export default function Loading() { return <p role="status">Loading your feed…</p> }
Mutating Data with Server Actions
To change data you use Server Actions: async functions marked "use server" that run on the server but can be called directly from a form or an event handler. They replace hand-written API routes for most mutations and integrate with cache revalidation.
tsx// app/todos/actions.ts "use server" import { revalidatePath } from "next/cache" import { db } from "@/lib/db" export async function addTodo(formData: FormData) { const title = String(formData.get("title") ?? "").trim() if (!title) return await db.todo.create({ data: { title } }) revalidatePath("/todos") // refresh the Server Component's data }
tsx// app/todos/page.tsx import { addTodo } from "./actions" export default function TodosPage() { return ( <form action={addTodo}> <input name="title" placeholder="New todo" /> <button type="submit">Add</button> </form> ) }
Common Pitfalls
Best Practices
- Default to Server Components. Start every component on the server and only add
"use client"when a feature forces it. - Fetch where you render. Co-locate data fetching with the component that needs it and let Next.js dedupe and cache identical
fetchcalls automatically. - Stream the slow parts. Wrap expensive sections in
<Suspense>(or useloading.tsx) so the shell paints instantly. - Keep the boundary thin. A small
LikeButtonorCollapsibleclient leaf is far cheaper than a client-rendered page. - Prefer Server Actions over ad-hoc API routes for mutations, and revalidate with
revalidatePathorrevalidateTagto keep the UI in sync.
Next Steps
Next Lesson
Testing Patterns