Skip to content

React Server Components — Next.js App Router Guide

DodaTech 9 min read

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

Error: 'useState' is not allowed in Server Components

You used a client-only hook in a server component. Either add "use client" at the top of the file, or extract the interactive piece into a separate client component and import it.

Error: Event handler 'onClick' cannot be passed to a Server Component

Server components cannot pass event handlers as props to their children. Wrap any component with event handlers in "use client". The boundary must be at or below the component that uses the handler.

Error: async/await is not supported in Client Components

You added "use client" to a component that uses async. Client components cannot be async. Move data fetching to a parent server component and pass the data as props.

Error: Text content does not match server-rendered HTML

A hydration mismatch. A client component rendered something different on the server vs browser. Common causes: using Date.now(), Math.random(), or localStorage without useEffect wrapping. Use useEffect to update after mount: const [mounted, setMounted] = useState(false).

Error: Layout must accept a children prop

In the App Router, every layout.tsx must accept and render {children}. This is the page content that will be injected into the layout. Without it, pages render as blank

Error: 'params' should be awaited in Next.js 15+

Next.js 15 made params a Promise. Access it with const { id } = await params; instead of params.id directly. Same for searchParams.

Practice Questions

  1. What is the default component type in the Next.js App Router? Server Component. Every .tsx file in app/ is a server component unless it has "use client" at the top.

  2. 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.

  3. What is the difference between loading.tsx and <Suspense>? loading.tsx is 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.

  4. Why does layout.tsx not remount on navigation? Layouts persist across navigations within their segment. This preserves state like scroll position, search filters, and sidebar state. Only page.tsx remounts.

  5. How does ISR work with fetch in server components? The next: { 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.tsx skeleton
  • 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

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