React Server Components — Next.js App Router Guide
In this tutorial, you'll learn about React Server Components. We cover key concepts, practical examples, and best practices.
React Server Components (RSC) move component rendering to the server, sending only pre-rendered HTML and minimal JavaScript to the browser for faster loads.
What You'll Learn
Build with React Server Components in the Next.js App Router — understand client vs server boundaries, fetch data directly in components, use streaming for progressive rendering, and structure layouts and pages.
Why It Matters
Traditional React apps send all JavaScript to the browser, even components that never need interactivity. RSC splits rendering: server components (zero JS) handle data fetching and static content; client components handle interactive UI. This can cut bundle size by 30–60%.
Real-World Use
A product listing page where the product grid is a server component fetching directly from a database, and the "Add to Cart" buttons are client components with minimal JS. The page loads instantly because the server sends pre-rendered HTML while the interactive bits hydrate separately. Doda Browser's settings dashboard uses this pattern — static config panels render on the server, toggle switches hydrate on the client.
Server vs Client Components
In the App Router, every component is a server component by default. You opt into client interactivity with "use client":
// This is a Server Component (default, no directive needed)
// It can: fetch data, access backend, read files, use async/await directly
// It cannot: useState, useEffect, onClick, browser APIs
async function ProductGrid() {
const products = await fetch("https://api.example.com/products").then(r => r.json());
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
{products.map((product: any) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// This is a Client Component ("use client" at the top)
// It can: useState, useEffect, event handlers, browser APIs
// It cannot: use async/await directly, access server-side resources
"use client";
import { useState } from "react";
export function AddToCart({ productId }: { productId: number }) {
const [added, setAdded] = useState(false);
return (
<button
onClick={() => {
setAdded(true);
console.log("Added", productId);
}}
style={{
background: added ? "#4caf50" : "#1976d2",
color: "#fff",
padding: "8px 16px",
border: "none",
borderRadius: 4,
cursor: "pointer",
}}
>
{added ? "Added ✓" : "Add to Cart"}
</button>
);
}
Data Fetching in Server Components
Server components can use async directly — no useEffect, no SWR, no React Query needed:
interface Post {
id: number;
title: string;
body: string;
}
async function BlogPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
next: { revalidate: 3600 }, // ISR: revalidate every hour
});
const posts: Post[] = await res.json();
return (
<div style={{ maxWidth: 800, margin: "0 auto" }}>
<h1 style={{ fontSize: "2rem", marginBottom: "1rem" }}>Blog Posts</h1>
{posts.slice(0, 10).map(post => (
<article key={post.id} style={{ marginBottom: "1.5rem", padding: "1rem", border: "1px solid #ddd", borderRadius: 8 }}>
<h2 style={{ fontSize: "1.25rem", marginBottom: "0.5rem" }}>{post.title}</h2>
<p>{post.body}</p>
</article>
))}
</div>
);
}
Expected output: A server-rendered page with 10 blog posts. Zero JavaScript is sent for the BlogPage component itself — just HTML and CSS.
The next: { revalidate: 3600 } option enables Incremental Static Regeneration (ISR). The page is cached after the first request and re-fetched every hour. This means database queries happen once, not on every visit.
Streaming with loading.tsx and Suspense
Next.js supports streaming — sending parts of the page as they finish rendering. Use loading.tsx for route-level loading and <Suspense> for component-level:
// app/products/loading.tsx — shown immediately while the page renders
export default function Loading() {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} style={{ height: 200, background: "#f0f0f0", borderRadius: 8, animation: "pulse 1.5s infinite" }} />
))}
</div>
);
}
For component-level streaming, wrap slow components in <Suspense>:
import { Suspense } from "react";
async function SlowDataFeed() {
const data = await fetch("https://api.example.com/slow-endpoint").then(r => r.json());
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<p>This text appears immediately.</p>
<Suspense fallback={<div style={{ color: "#666" }}>Loading data feed...</div>}>
<SlowDataFeed />
</Suspense>
</div>
);
}
Expected output: The heading and paragraph render instantly. "Loading data feed..." shows while the slow API call completes, then the data replaces it — all without a full page navigation.
Layout vs Page
In the App Router, layout.tsx wraps page.tsx and persists across navigations — it does NOT remount:
// app/layout.tsx — shared shell, persists across routes
import { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<header style={{ borderBottom: "1px solid #ddd", padding: "1rem 2rem" }}>
<nav style={{ display: "flex", gap: "1rem" }}>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
</header>
<main style={{ padding: "2rem", maxWidth: 1200, margin: "0 auto" }}>
{children}
</main>
</body>
</html>
);
}
// app/products/layout.tsx — nested layout for products section
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<aside style={{ float: "right", width: 250, background: "#f9f9f9", padding: "1rem" }}>
<h3>Filters</h3>
{/* filter controls */}
</aside>
<div style={{ marginRight: 280 }}>{children}</div>
</section>
);
}
// app/products/page.tsx — the actual page content
export default function ProductsPage() {
return <h2>All Products</h2>;
}
Key difference: layout.tsx preserves state (like scroll position, React state) when navigating between pages in the same layout segment. page.tsx unmounts and remounts on every navigation.
Client Component Best Practices
Keep client components small. A common pattern is the "thin client wrapper":
// Server Component
import { AddToCartButton } from "./AddToCartButton";
async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p style={{ fontSize: "1.5rem", fontWeight: "bold" }}>${product.price}</p>
{/* Only the button is a client component — minimal JS */}
<AddToCartButton productId={product.id} />
</div>
);
}
"use client";
import { useState } from "react";
export function AddToCartButton({ productId }: { productId: number }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Add ({count})</button>;
}
This pattern sends the button's JavaScript to the browser — nothing else. React and TypeScript keep the full product page as zero-JS HTML.
Common Errors with Server Components
Practice Questions
What is the default component type in the Next.js App Router? Server Component. Every
.tsxfile inapp/is a server component unless it has"use client"at the top.How do you handle interactivity in a server component? Extract the interactive part into a separate client component (with
"use client") and import it into the server component. Keep client components small — only the minimum needed for interactivity.What is the difference between
loading.tsxand<Suspense>?loading.tsxis Next.js's route-level loading state for the entire page segment.<Suspense>is a React API for component-level streaming within an already-loaded page.Why does
layout.tsxnot remount on navigation? Layouts persist across navigations within their segment. This preserves state like scroll position, search filters, and sidebar state. Onlypage.tsxremounts.How does ISR work with
fetchin server components? Thenext: { revalidate: N }option caches the fetch result for N seconds. After N seconds, the next request triggers a background re-fetch. Users always get the cached version until the new data is ready.
Challenge
Build a three-tier streaming product page: (1) a layout with navigation (server component), (2) a product header with title and price that loads immediately (server component with direct data access), (3) a reviews section wrapped in <Suspense> that streams in after 2 seconds (simulated delay). The "Write Review" form should be a client component with form state. Measure the time-to-first-byte vs time-to-interactive.
Real-World Task
Take one page in your current Next.js project and identify every component. Classify each as "must be client" (uses hooks, events, browser APIs), "can be server" (pure rendering, data fetching), or "hybrid" (server wrapper, client interactive parts). Move 3 components from client to server and measure the bundle size difference.
Mini Project: Blog with Streaming Comments
Build a Next.js App Router blog page with:
- Server component rendering the post content (static HTML, no JS)
- A
loading.tsxskeleton - Comments section wrapped in
<Suspense>with a 2-second simulated delay - A client component for submitting new comments (form with
useState)
Measure the difference in initial JS bundle size compared to a traditional pages-router approach where everything is a client component.
Learning Path
flowchart LR A[React Folder Structure] --> B[React State Management] B --> C[React Server Components] C --> D[Next.js App Router] D --> E[Next.js Advanced Patterns] style C fill:#4a90d9,color:#fff
Next Steps
- Previous: React State Management — Redux, Zustand, Context API Compared, React Folder Structure — Best Practices
- Next: Next.js Cheatsheet, React TypeScript — Using TypeScript with React
- Related: Node.js, Custom Hooks in React — Reusable Logic
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Doda Browser's settings dashboard uses the same RSC pattern — server components render config panels from backend data while only the toggle switches and input fields ship as client JavaScript, keeping the settings page under 5 KB of JS while rendering 40+ configurable options.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro