Dev Pattern Labs logo
Dev Pattern Labs Blog
Next.js 13 min readOct 9, 2026

Server Components vs Client Components in Next.js: When to Use Each

Learn the fundamental differences between React Server Components and Client Components in Next.js. Understand bundle size impact, data access patterns, composition strategies, and the 'use client' boundary.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

Split screen showing server and client component code
Executive Summary & Key Insight

Learn the fundamental differences between React Server Components and Client Components in Next.js. Understand bundle size impact, data access patterns, composition strategies, and the 'use client' boundary. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.

Advertisement

React Server Components (RSC) represent the single biggest architectural shift in React's history, and Next.js's App Router is the first production-ready implementation of them at scale. If you've migrated from the Pages Router—or you're starting a fresh Next.js 14+ project—you've already encountered the two-world model: some components run exclusively on the server, others run on the client, and the rules governing each are fundamentally different. Understanding where that boundary sits, why it exists, and how to compose across it separates developers who fight the framework from those who unlock its full potential.

At its core, the distinction is about where a component renders and what it can access. Server Components execute during the request lifecycle on the server, produce serialized React trees, and never ship their own JavaScript to the browser. Client Components, which are the traditional React components you've always written, are pre-rendered on the server for the initial HTML but also ship their JavaScript bundle so they can hydrate and respond to user interactions. The two component types coexist in the same tree—you just need to know the rules.

The mental model shift is significant but well worth the investment. Once you internalize the boundaries, you'll write applications that ship far less JavaScript, perform fewer waterfall fetches, access databases and secrets directly from components, and still deliver rich interactive experiences where they matter. This guide walks through every dimension of that model with concrete code examples you can apply today.


Prerequisites

Before diving in, make sure you're comfortable with:

  • Next.js 13.4+ / 14+ App Router — the app/ directory, layouts, and the file-based routing system
  • TypeScript fundamentals — generics, async/await, type annotations
  • React basics — JSX, props, state, hooks (useState, useEffect, useRef)
  • Basic Node.js — understanding of server-side execution vs. browser execution

All code examples target Next.js 14 with TypeScript and assume the app/ directory structure.


1. RSC Fundamentals — How Server Components Work

Every component inside the app/ directory is a Server Component by default. There is no opt-in directive needed; Server Components are the base case, and you opt out to the client with the "use client" directive.

When Next.js processes a Server Component, it executes the component function on the server during the request, serializes the resulting React element tree into a special RSC payload format, and streams it to the browser. Critically, no component JavaScript is included in the client bundle—only the rendered output travels across the wire.

// app/products/page.tsx — This is a Server Component by default
// No "use client" directive = runs ONLY on the server

import { db } from "@/lib/db";

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

export default async function ProductsPage() {
  // Direct database access — this code never reaches the browser
  const products = await db.query<Product[]>(
    "SELECT id, name, price, stock FROM products WHERE active = true ORDER BY name"
  );

  return (
    <main className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">Our Products</h1>
      <ul className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {products.map((product) => (
          <li key={product.id} className="border rounded-lg p-4">
            <h2 className="font-semibold">{product.name}</h2>
            <p className="text-gray-600">${product.price.toFixed(2)}</p>
            <span className={product.stock > 0 ? "text-green-600" : "text-red-600"}>
              {product.stock > 0 ? `${product.stock} in stock` : "Out of stock"}
            </span>
          </li>
        ))}
      </ul>
    </main>
  );
}

Notice the component is async—Server Components can be async functions natively, without useEffect or state management libraries to orchestrate fetches.


2. Bundle Size Impact and JavaScript Reduction

The performance argument for Server Components is straightforward: if a component never interacts with the user, its code should never reach the user's device. Consider a syntax highlighting component powered by a 120 KB library like highlight.js. As a Server Component, that library runs at build/request time; zero bytes of it land in the client bundle.

// app/docs/[slug]/page.tsx — Server Component with heavy library — zero bundle cost
import { highlight } from "highlight.js"; // 120 KB library — stays on server!
import { getDocBySlug } from "@/lib/docs";

interface Props {
  params: { slug: string };
}

export default async function DocPage({ params }: Props) {
  const doc = await getDocBySlug(params.slug);

  const highlightedCode = highlight(doc.codeSnippet, {
    language: "typescript",
  }).value;

  return (
    <article className="prose lg:prose-xl mx-auto py-12">
      <h1>{doc.title}</h1>
      <p>{doc.summary}</p>
      {/* dangerouslySetInnerHTML is fine here — content is server-controlled */}
      <pre>
        <code
          className="hljs language-typescript"
          dangerouslySetInnerHTML={{ __html: highlightedCode }}
        />
      </pre>
    </article>
  );
}

The resulting page ships only the HTML with pre-highlighted code. The highlight.js module is never bundled for the client. For data-heavy dashboards, marketing sites, or documentation portals, this pattern alone can cut total JavaScript payload by 40–70%.


3. Data Access in Server Components

Server Components have direct access to the server environment: databases, the filesystem, internal APIs, and environment variables that would be dangerous to expose to the browser. You can co-locate your data-fetching logic directly inside the component without any API route indirection.

// lib/data.ts — shared server-only data access layer
import { cookies } from "next/headers";
import { verifySession } from "@/lib/auth";

export async function getCurrentUser() {
  const cookieStore = cookies();
  const sessionToken = cookieStore.get("session")?.value;

  if (!sessionToken) return null;

  // Reads from DB using a secret never exposed to client
  return verifySession(sessionToken, process.env.JWT_SECRET!);
}

// app/dashboard/page.tsx — Server Component accessing auth + DB
import { getCurrentUser } from "@/lib/data";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";

export default async function DashboardPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect("/login"); // Server-side redirect — no flicker
  }

  const stats = await db.query(
    "SELECT COUNT(*) as orders, SUM(total) as revenue FROM orders WHERE user_id = $1",
    [user.id]
  );

  return (
    <section>
      <h1>Welcome back, {user.name}</h1>
      <p>Total orders: {stats.orders}</p>
      <p>Total revenue: ${stats.revenue}</p>
    </section>
  );
}

Notice that process.env.JWT_SECRET is used directly—it stays on the server and never leaks to the browser.


4. Event Handlers and Interactivity — Client Components

Server Components cannot handle user interactions. They have no access to browser APIs, cannot attach event listeners, and cannot maintain state between renders. The moment you need onClick, onChange, form submissions, or any DOM interaction, you need a Client Component.

// components/AddToCartButton.tsx — must be a Client Component
"use client";

import { useState, useTransition } from "react";
import { addToCart } from "@/actions/cart";

interface Props {
  productId: string;
  productName: string;
}

export function AddToCartButton({ productId, productName }: Props) {
  const [added, setAdded] = useState(false);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      await addToCart(productId);
      setAdded(true);
      setTimeout(() => setAdded(false), 2000);
    });
  };

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
    >
      {isPending ? "Adding…" : added ? "✓ Added!" : `Add ${productName} to Cart`}
    </button>
  );
}

The "use client" directive at the top of the file is the explicit signal to Next.js that this module and everything it imports should be included in the client bundle.


5. React Hooks in Client Components

All React hooks—useState, useEffect, useRef, useReducer, useContext, and all third-party hooks—are available only in Client Components. This is because hooks rely on React's runtime state management, which lives in the browser.

// components/SearchBar.tsx
"use client";

import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";

export function SearchBar() {
  const [query, setQuery] = useState("");
  const [suggestions, setSuggestions] = useState<string[]>([]);
  const inputRef = useRef<HTMLInputElement>(null);
  const router = useRouter();

  // useEffect is only available in Client Components
  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  useEffect(() => {
    if (query.length < 2) {
      setSuggestions([]);
      return;
    }
    const controller = new AbortController();
    fetch(`/api/suggestions?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then(setSuggestions)
      .catch(() => {}); // ignore abort errors

    return () => controller.abort();
  }, [query]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    router.push(`/search?q=${encodeURIComponent(query)}`);
  };

  return (
    <form onSubmit={handleSubmit} className="relative">
      <input
        ref={inputRef}
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products…"
        className="w-full border rounded-lg px-4 py-2"
      />
      {suggestions.length > 0 && (
        <ul className="absolute top-full left-0 right-0 bg-white border rounded-lg shadow-lg z-10">
          {suggestions.map((s) => (
            <li
              key={s}
              onClick={() => { setQuery(s); setSuggestions([]); }}
              className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
            >
              {s}
            </li>
          ))}
        </ul>
      )}
    </form>
  );
}

6. The "use client" Boundary and How It Propagates

The "use client" directive doesn't just mark a single component — it marks a module boundary. Every module imported by a "use client" file also becomes part of the client bundle, recursively. Think of it as a waterfall: the directive at the top of a file "colors" the entire import tree beneath it.

// ❌ Anti-pattern: placing "use client" too high contaminates the tree
// app/layout.tsx
"use client"; // ← This forces EVERYTHING in the layout to be client-side

import { Header } from "@/components/Header";    // now client
import { Footer } from "@/components/Footer";    // now client — even if static!
import { Analytics } from "@/components/Analytics"; // now client
// ✅ Correct: keep "use client" as deep/narrow as possible

// app/layout.tsx — Server Component (no directive)
import { Header } from "@/components/Header";        // Server Component
import { Footer } from "@/components/Footer";        // Server Component
import { ThemeToggle } from "@/components/ThemeToggle"; // Client Component only where needed

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Header />         {/* Renders on server — no JS bundle */}
        <main>{children}</main>
        <Footer />         {/* Renders on server — no JS bundle */}
        <ThemeToggle />    {/* Only this tiny component is client-side */}
      </body>
    </html>
  );
}

Push the "use client" boundary as close to the leaves of your component tree as possible. This is the single most impactful optimization pattern in the App Router.


7. Composition Patterns — Mixing Server and Client

The most powerful—and initially confusing—pattern is passing Server Components as children or props to Client Components. A Client Component cannot import a Server Component, but it can render one that was passed to it from a Server Component parent.

// components/Modal.tsx — Client Component (handles open/close state)
"use client";

import { useState } from "react";

interface Props {
  trigger: React.ReactNode;
  children: React.ReactNode; // Can be a Server Component subtree!
}

export function Modal({ trigger, children }: Props) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <div onClick={() => setIsOpen(true)}>{trigger}</div>
      {isOpen && (
        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
          <div className="bg-white rounded-xl p-6 max-w-lg w-full">
            <button
              onClick={() => setIsOpen(false)}
              className="absolute top-4 right-4 text-gray-500"
            >
              ✕
            </button>
            {children}  {/* Server-rendered content displayed inside a client shell */}
          </div>
        </div>
      )}
    </>
  );
}

// app/products/[id]/page.tsx — Server Component orchestrating the composition
import { Modal } from "@/components/Modal";
import { ProductDetails } from "@/components/ProductDetails"; // Server Component
import { getProduct } from "@/lib/data";

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id); // Direct DB access

  return (
    <div>
      <h1>{product.name}</h1>
      {/* Server Component passes Server-rendered content into Client Component */}
      <Modal trigger={<button>View Full Details</button>}>
        <ProductDetails product={product} /> {/* Rendered on server! */}
      </Modal>
    </div>
  );
}

The Server Component ProductPage fetches data and passes a Server-rendered <ProductDetails> subtree into the Client Component Modal as children. The modal gets interactive open/close behavior without pulling ProductDetails into the client bundle.


8. Common Mistakes and Anti-Patterns

Understanding what not to do accelerates your learning considerably. Here are the most common pitfalls:

// ❌ Mistake 1: Importing a Client Component into a Server Component that passes
// non-serializable props (functions, class instances)

// Server Component
import { Chart } from "@/components/Chart"; // Client Component

async function StatsPage() {
  const data = await getStats();
  // ❌ Passing a class instance — not serializable across the RSC boundary
  return <Chart formatter={new Intl.NumberFormat("en-US")} data={data} />;
}

// ✅ Fix: serialize what you need before passing
async function StatsPage() {
  const data = await getStats();
  // Pre-format on the server, pass plain strings
  const formattedData = data.map((d) => ({
    ...d,
    label: new Intl.NumberFormat("en-US").format(d.value),
  }));
  return <Chart data={formattedData} />;
}
// ❌ Mistake 2: Fetching data in a Client Component when a Server Component would work
"use client";
import { useEffect, useState } from "react";

function BlogList() {
  const [posts, setPosts] = useState([]);
  useEffect(() => {
    fetch("/api/posts").then(r => r.json()).then(setPosts); // Extra round-trip!
  }, []);
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// ✅ Fix: fetch directly in a Server Component — no round-trip, no loading state
async function BlogList() {
  const posts = await db.query("SELECT id, title FROM posts ORDER BY created_at DESC");
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// ❌ Mistake 3: Adding "use client" to a file just to use next/navigation
// and then importing heavy Server-only utilities into it

"use client";
import { redirect } from "next/navigation"; // ← redirect() doesn't work in Client Components!
import { db } from "@/lib/db"; // ← Will throw — db uses Node APIs not available in browser

// ✅ Fix: use redirect() in Server Components or middleware; use router.push() on client
"use client";
import { useRouter } from "next/navigation";

function LogoutButton() {
  const router = useRouter();
  return <button onClick={() => { /* call logout API */ router.push("/login"); }}>Logout</button>;
}

Server vs. Client Component Capability Comparison

CapabilityServer ComponentClient Component
Async/await at component level✅❌
Direct database access✅❌
Access to process.env secrets✅❌
Access to cookies() / headers()✅❌
Included in JS bundle❌✅
useState / useReducer❌✅
useEffect / useLayoutEffect❌✅
useRef / useContext❌✅
Event handlers (onClick, etc.)❌✅
Browser APIs (window, document)❌✅
Third-party hooks❌✅
Can render Client Components✅✅
Can import Server Components✅❌ (pass as props/children only)
redirect() from next/navigation✅❌
Server Actions ("use server")✅✅ (via import or props)

Conclusion

React Server Components in Next.js's App Router aren't just a new API—they're a different mental model for building web applications. The key takeaways from this guide:

  1. Default to Server Components. Every app/ file is a Server Component unless you add "use client". Start there and only opt into the client when you need interactivity.

  2. Push "use client" to the leaves. The directive marks a module boundary that propagates down the import tree. Keeping it narrow minimizes your client bundle.

  3. Co-locate data fetching. Server Components can be async and access your database, secrets, and server-only APIs directly—no API routes required for most data needs.

  4. Use composition to bridge the boundary. Client Components can receive Server Component subtrees as children or props, giving you interactive shells around server-rendered content.

  5. Never import Server Components into Client Components. Pass them as props or children from a parent Server Component instead.

  6. Non-serializable values (functions, class instances) cannot cross the RSC boundary from server to client—pre-process them into plain data first.

Mastering this model is what separates good Next.js applications from exceptional ones. Once it clicks, you'll find yourself writing leaner, faster, more secure applications with considerably less boilerplate than the Pages Router ever allowed.

Tags:#Next.js#React Server Components#App Router#Performance
Dev Pattern Labs Engineering Team

Written by Dev Pattern LabsCore Engineering

Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.

Advertisement

Recommended Companion Articles

API documentation and code on a monitor
Next.js

Building REST APIs with Next.js Route Handlers

Learn to build production-ready REST APIs with Next.js Route Handlers. Covers GET, POST, PUT, DELETE methods, NextRequest/NextResponse, query params, CORS, streaming responses, and dynamic segments.

11 min read
Developer working on Next.js caching configuration
Next.js

Next.js 15 Caching Strategies Explained

Understand all Next.js 15 caching layers: fetch cache options, Route Segment Config, full-route cache, data cache, router cache, and on-demand revalidation with revalidatePath and revalidateTag.

13 min read