Next.js Server Components
A practical guide to server components, the use client directive, and rendering boundaries.
TL;DR
- 01Server components run on the server and send HTML to the browser.
- 02Use client components with the "use client" directive for interactivity.
- 03Mix both types to optimize performance and security.
Tips
- 01Keep the "use client" boundary as low as possible in the tree to maximize server rendering and minimize the client bundle size.
- 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
- 01Never include environment secrets in client components, even as props, since they become visible in the browser bundle and HTML.
- 02Adding <code>"use client"</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
asyncfunctions andawaitdata 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 likelocalStorage. - 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
childrenor 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
importa 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-onlypackage converts that silent leak into a build error, so the mistake is caught in CI instead of in production. - Pair it with
client-onlyin 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 cachedirective is the current recommended way to cache fetches, components, or whole routes, replacing the olderunstable_cacheAPI for new code. - Revalidate a specific cache entry on demand with
revalidateTag("post-my-slug")after a mutation, instead of waiting forcacheLifeto expire. fetchcalls still support the older{ next: { revalidate: 3600 } }option directly, which works without enabling theuse cachedirective.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
Yes — server components run in a Node.js environment and can call your database, ORM, or internal services directly using async/await. You only need an API route when the client itself needs to fetch data after the initial page load.
Add "use client" as the very first line of the file to make it a client component. Only components that actually need browser APIs, event listeners, or React hooks need this directive — keep it scoped to the smallest component possible.
Pass the data as props from the server component down to the client component — but only serialize-safe values like strings, numbers, and plain objects. Functions, class instances, and Promises cannot cross the server-client boundary as props.
Any module imported by a client component (directly or transitively) gets bundled for the browser. Use the server-only package in files that must stay server-side — it throws a build-time error if that module is ever pulled into the client graph.
You can't import a server component inside a client component, but you can pass one as children or a prop — the server component still renders on the server, and only its output HTML is sent to the client component as a slot. This is the key pattern for keeping heavy server logic out of the client bundle.