Next.js Server Components

A practical guide to server components, the use client directive, and rendering boundaries.

TL;DR

  1. 01Server components run on the server and send HTML to the browser.
  2. 02Use client components with the "use client" directive for interactivity.
  3. 03Mix both types to optimize performance and security.

Tips

  1. 01Keep the "use client" boundary as low as possible in the tree to maximize server rendering and minimize the client bundle size.
  2. 02Use the <code>server-only</code> and <code>client-only</code> packages together so the build fails fast if a module crosses the wrong boundary.

Warnings

  1. 01Never include environment secrets in client components, even as props, since they become visible in the browser bundle and HTML.
  2. 02Adding <code>&quot;use client&quot;</code> too high in the tree makes every component it imports client-side too, silently bloating the JavaScript bundle.

Rendering Model

  • A Server Component never sends JSX or markdown to the browser — React serializes its output into a streamable RSC payload, not regular JSON.
    // app/page.tsx — runs entirely on the server, no client JS for this tree
    export default async function Page() {
      const post = await db.post.findFirst();
      return <article>{post.title}</article>;
    }
    
  • The payload uses the React Flight protocol: each chunk maps to one resolved component, so the client renders pieces as they arrive instead of waiting for the whole tree.
  • Client Components referenced inside the payload show up as module references the browser fetches separately, keeping server-only code out of that bundle.
  • Because rendering happens once on the server, Server Components can be async functions and await data directly — there is no hydration step for their output.
  • Nothing in a Server Component re-executes in the browser, so state, refs, and lifecycle hooks have no meaning there.

Server vs Client Tradeoffs

  • Every component starts as a Server Component until something forces it to the client — so the real question is what justifies that cost, not how to flip the switch.
    // Stays a Server Component: no interactivity, no hooks needed
    export default async function PriceTag({ productId }) {
      const price = await getPrice(productId);
      return <span>${price}</span>;
    }
    
  • Choose a Client Component only when the UI needs useState, useEffect, event handlers, or browser-only APIs like localStorage.
  • Server Components shrink the JavaScript bundle because their code and dependencies never ship — a heavy markdown parser or date library used only on the server costs the client nothing.
  • Server Components can read environment secrets and query databases directly; Client Components can only see what is explicitly passed to them as props.
  • A page can mix both freely — the tradeoff is per-component, not per-page, so isolate interactivity into the smallest possible Client Component.
    // Only LikeButton ships JS; the rest of the page stays server-rendered
    import LikeButton from "./LikeButton"; // "use client" lives inside this file
    export default async function Post({ id }) {
      const post = await getPost(id);
      return <article>{post.body}<LikeButton postId={id} /></article>;
    }
    

Composition Boundaries

  • A Client Component can render a Server Component without importing it — pass it through children or another prop instead.
    // ClientShell.tsx ("use client") never imports ServerSidebar directly
    export default function ClientShell({ children }) {
      return <div className="shell">{children}</div>;
    }
    // app/page.tsx (Server Component) composes them together
    <ClientShell><ServerSidebar /></ClientShell>
    
  • The nested Server Component still renders on the server first — only its finished output crosses into the Client Component as a slot.
  • A Client Component can never import a Server Component file directly; that import would pull server code into the client module graph and fail to build.
  • Props flowing from a Server Component into a Client Component must be plain serializable values: strings, numbers, arrays, dates, and plain objects.
    // OK — every value below survives the server-to-client boundary
    <ClientChart data={rows} title="Sales" updatedAt={new Date()} />
    // Not OK — functions and class instances cannot cross as props
    // <ClientChart onSort={sortRows} />
    
  • This children-as-slot pattern is the main way teams keep a heavy data-fetching tree server-rendered while still nesting it inside an interactive shell.

Protecting Server-Only Code

  • A Server Component's database calls and API keys are invisible to the browser by default — but only if nothing pulls that module into a Client Component's import graph.
    import "server-only";
    // Any client-side import of this file now fails at build time, not runtime
    export async function getSecretData() {
      return db.query("SELECT * FROM secrets");
    }
    
  • A single accidental import of a server-only utility from a Client Component silently ships that code, and anything it touches, to every visitor's browser.
  • The server-only package converts that silent leak into a build error, so the mistake is caught in CI instead of in production.
  • Pair it with client-only in browser-specific utilities so the two packages catch boundary mistakes in both directions.
  • Treat any value passed as a prop to a Client Component as public — never pass raw secrets, tokens, or full database rows; pass only the sanitized fields the UI needs.

Caching Server Output

  • Server Components re-run on every request by default — without caching, a page with five database calls makes five fresh queries on every visit.
    import { cacheLife, cacheTag } from "next/cache";
    async function getPost(slug: string) {
      "use cache"; // marks this function's result as cacheable
      cacheTag(`post-${slug}`);
      cacheLife("hours");
      return db.post.findUnique({ where: { slug } });
    }
    
  • The use cache directive is the current recommended way to cache fetches, components, or whole routes, replacing the older unstable_cache API for new code.
  • Revalidate a specific cache entry on demand with revalidateTag("post-my-slug") after a mutation, instead of waiting for cacheLife to expire.
  • fetch calls still support the older { next: { revalidate: 3600 } } option directly, which works without enabling the use cache directive.
    const res = await fetch("https://api.example.com/data", { next: { revalidate: 3600 } });
    
  • Wrap slow server queries in <Suspense> so the rest of the page streams in while one section is still loading.
    <Suspense fallback={<p>Loading…</p>}>
      <SlowServerSection />
    </Suspense>
    

FAQ