For most of React's history, every component ended up in the browser. Even a product page that only displays text shipped its code and every library it imported, then fetched its data from an API endpoint you had to build and secure. React Server Components flip that default: in the Next.js App Router, components render on the server unless you opt out, and only the interactive parts ship JavaScript.

I've seen teams add "use client" to a root layout to make one context provider work, and pass database rows straight into Client Components, publishing fields nobody meant to expose. Both mistakes come from not knowing where the boundary between server and client actually runs.

Below I build that mental model and the patterns that follow from it: composition, serialization, data fetching and streaming, Server Functions, and secrets. It ends with the decision table I use when I'm unsure where a component belongs.

The mental model: two kinds of components

A Server Component runs only on the server, at build time or per request. It can be an async function that queries a database or an internal API directly, using secrets if it needs them. Its code never reaches the browser; React sends only the rendered result, in a serialized format called the RSC Payload. In exchange, it can't hold state, run effects, handle events, or use browser APIs.

A Client Component is the React you already know, with state, effects, and event handlers. Its code ships to the browser, where React hydrates it by attaching event handlers to the server-rendered HTML.

In the App Router, every component is a Server Component by default, and the "use client" directive opts a module into the client. Here's a product page that uses both:

app/products/[id]/page.tsxTSX
import { notFound } from "next/navigation";
import { getProduct } from "@/lib/products";
import { CopyLinkButton } from "./copy-link-button";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
 
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <CopyLinkButton />
    </main>
  );
}
app/products/[id]/copy-link-button.tsxTSX
"use client";
 
import { useState } from "react";
 
export function CopyLinkButton() {
  const [label, setLabel] = useState("Copy link");
 
  async function copyLink() {
    try {
      await navigator.clipboard.writeText(window.location.href);
      setLabel("Copied");
    } catch {
      setLabel("Copy failed");
    }
  }
 
  return (
    <button type="button" onClick={copyLink}>
      {label}
    </button>
  );
}

The page awaits params (a Promise since Next.js 15) and the product, then returns markup: no API route, no useEffect, no loading state. Of the code behind this page, only CopyLinkButton ships to the browser; the page, getProduct, and their imports stay on the server.

What "use client" actually marks

The directive doesn't mark a component. It marks a module, the point where the client module graph begins, and it must come first in the file, above any imports (comments may precede it). Every module that file imports, directly or transitively, is bundled for the browser too, directive or not:

Text
app/products/[id]/page.tsx        Server Component (the default)
├── lib/products.ts               stays on the server
├── lib/format-price.ts           runs on the server for the page...
└── components/cart-drawer.tsx    "use client": the client graph starts here
    ├── components/cart-line.tsx  client code, no directive needed
    └── lib/format-price.ts       ...and also ships in the browser bundle

Three consequences follow. You need the directive only at entry points, the files server code imports directly; cart-line.tsx is client code because of who imports it. A module without a directive can run on both sides, like format-price.ts. And the higher the directive sits, the more code ships, so I push it down to the smallest interactive leaf: the button, not the page.

The same rule explains a common npm error: a library component that uses hooks but lacks the directive fails in a Server Component. Give it a client entry point of your own (acme-carousel stands in for any such package):

components/carousel.tsxTSX
"use client";
 
import { Carousel } from "acme-carousel";
 
export { Carousel };

Passing Server Components into Client Components

Because the directive follows imports, a Client Component can't import a Server Component; the import would turn it into client code, and React doesn't support async Client Components. It can render Server Components it receives as props, though: the parent Server Component renders both, so the server piece never enters the client module graph. The most common slot is children:

components/expandable.tsxTSX
"use client";
 
import { useState, type ReactNode } from "react";
 
export function Expandable({
  title,
  children,
}: {
  title: string;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(false);
 
  return (
    <section>
      <button
        type="button"
        aria-expanded={open}
        onClick={() => setOpen((value) => !value)}
      >
        {title}
      </button>
      <div hidden={!open}>{children}</div>
    </section>
  );
}
app/products/[id]/reviews.tsxTSX
import { getReviews } from "@/lib/products";
 
export async function Reviews({ productId }: { productId: string }) {
  const reviews = await getReviews(productId);
 
  if (reviews.length === 0) {
    return <p>No reviews yet.</p>;
  }
 
  return (
    <ul>
      {reviews.map((review) => (
        <li key={review.id}>
          <p>
            {review.author} rated it {review.rating} out of 5
          </p>
          <p>{review.body}</p>
        </li>
      ))}
    </ul>
  );
}

The page nests one inside the other:

app/products/[id]/page.tsxTSX
<Expandable title="Customer reviews">
  <Reviews productId={id} />
</Expandable>

React renders Reviews on the server, and Expandable receives the finished output as a slot it can place but not inspect. The toggle ships as JavaScript; the reviews and the code that loads them don't. Any prop works the same way, such as a sidebar prop on a client layout shell. The limit is that the Client Component can't pass its own state into the slot.

Where context providers belong

React context doesn't work in Server Components, which tempts people to mark the root layout as a client file. Instead, give the provider its own client module that accepts children:

app/theme-provider.tsxTSX
"use client";
 
import { createContext, useContext, useState, type ReactNode } from "react";
 
type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggleTheme: () => void };
 
const ThemeContext = createContext<ThemeContextValue | null>(null);
 
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggleTheme = () => setTheme((t) => (t === "light" ? "dark" : "light"));
 
  return <ThemeContext value={{ theme, toggleTheme }}>{children}</ThemeContext>;
}
 
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used inside ThemeProvider");
  return context;
}
app/layout.tsxTSX
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { ThemeProvider } from "./theme-provider";
 
export const metadata: Metadata = { title: "Acme Store" };
 
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

The layout stays a Server Component, so it can still export metadata, and pages rendered through children stay on the server. Since React 19 the context itself works as a provider, hence no .Provider. Render each provider only as high as its consumers need: a dashboard-only provider belongs in the dashboard's layout.

What can cross the boundary

When a Server Component renders a Client Component, React serializes the props into the RSC Payload. Its format isn't JSON, and it supports more: primitives (including bigint and undefined), plain objects and arrays, Date, Map, Set, typed arrays, promises, JSX, and Server Functions. It rejects other functions, class instances (including many ORM records), and symbols not created with Symbol.for, so an onSelect callback passed from a Server Component throws an error. Define handlers inside the Client Component instead. The React reference for "use client" has the full list.

Two caveats remain even when serialization succeeds:

  • Dates survive; formatting may not. If a Client Component calls toLocaleString() on a Date, the server pre-render and the browser may use different time zones, and React reports a hydration mismatch. Format on the server, or pass an explicit timeZone to Intl.DateTimeFormat.
  • Maps and Sets survive only this trip. Anything that goes through JSON, such as a Route Handler response or localStorage, turns a Map into an empty object and a Date into a string.
app/account/page.tsxTSX
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import { ProfileForm } from "./profile-form";
 
export default async function AccountPage() {
  const user = await getCurrentUser();
  if (!user) redirect("/login");
 
  // Not user={user}: that would serialize every column, password hash included
  const profile = {
    name: user.name,
    email: user.email,
    memberSince: user.createdAt, // a Date, which serializes fine
  };
 
  return <ProfileForm profile={profile} />;
}

Here getCurrentUser returns the full user record, and ProfileForm is a Client Component.

Keep secrets on the server with server-only

Some modules must never reach the browser: database clients, code that reads API keys, proprietary logic. One stray import from a client file pulls them into the bundle. In client code, Next.js inlines only environment variables prefixed with NEXT_PUBLIC_ and replaces the rest with an empty string, which turns a would-be leak into a confusing bug, and the tempting fix of adding the prefix publishes the key.

The server-only package turns that mistake into a build error:

lib/products.tsTypeScript
import "server-only";
import { cache } from "react";
 
export type Product = { id: string; name: string; description: string };
export type Review = { id: string; author: string; rating: number; body: string };
 
async function storeApi<T>(path: string): Promise<T | null> {
  const res = await fetch(`${process.env.STORE_API_URL}${path}`, {
    headers: { Authorization: `Bearer ${process.env.STORE_API_KEY}` },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Store API returned ${res.status} for ${path}`);
  return (await res.json()) as T;
}
 
export const getProduct = cache((id: string) =>
  storeApi<Product>(`/products/${encodeURIComponent(id)}`),
);
 
export const getReviews = cache(async (productId: string) => {
  const path = `/products/${encodeURIComponent(productId)}/reviews`;
  return (await storeApi<Review[]>(path)) ?? [];
});
 
// ...plus createReview(), which the review form's Server Function calls later

If a client module imports this file, even through another file, the build fails and points at the import. Next.js recognizes server-only without the package installed, but I add it anyway so dependency linters stay quiet:

Terminal
npm install server-only

Fetching data without waterfalls

Data fetching in a Server Component reads like ordinary async code, which is how waterfalls sneak in: each await holds the next request back until the previous one finishes.

Run independent requests in parallel

When requests don't depend on each other, start them together:

app/products/[id]/page.tsxTSX
const { id } = await params;
const product = await getProduct(id); 
const reviews = await getReviews(id); 
const [product, reviews] = await Promise.all([getProduct(id), getReviews(id)]); 
if (!product) notFound();

The page now waits for the slowest request instead of the sum. Promise.all rejects as soon as one promise fails, so for optional data use Promise.allSettled and render a fallback for whatever failed.

Start a child's request early

A subtler waterfall forms between components. If the page awaits the product before rendering <Reviews productId={id} />, the reviews request can't start until the product arrives. The fix is to start it early and let the child reuse it.

That's the job of React's cache in lib/products.ts. It memoizes a function for the duration of one server request, so if the page calls getReviews(id) without awaiting it, the later call inside Reviews gets the same in-flight promise. Define cached functions at module level and call them during rendering, since calls outside a render skip the cache.

Stream slow sections with Suspense

Parallel requests still wait for the slowest one. Streaming removes that wait for anything that can arrive later: wrap a slow async component in <Suspense>, and Next.js sends everything outside the boundary as soon as it's ready, shows the fallback in its place, and streams in the finished HTML when the data resolves. Here's the product page with every piece in place:

app/products/[id]/page.tsxTSX
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { Expandable } from "@/components/expandable";
import { getProduct, getReviews } from "@/lib/products";
import { CopyLinkButton } from "./copy-link-button";
import { Reviews } from "./reviews";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  void getReviews(id); // start the slow request now; Reviews reuses it
  const product = await getProduct(id);
  if (!product) notFound();
 
  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <CopyLinkButton />
      <Expandable title="Customer reviews">
        <Suspense fallback={<p>Loading reviews...</p>}>
          <Reviews productId={id} />
        </Suspense>
      </Expandable>
    </main>
  );
}

Line 14 starts the reviews request (void marks it as deliberately not awaited), the product renders as soon as it loads, and the reviews stream in after. When placing boundaries, I follow a few rules:

  • Give each independent slow section its own boundary.
  • Size fallbacks like the content they replace, so the layout doesn't jump.
  • Use a loading.tsx file to wrap a whole route segment in a boundary.
  • Decide the status code before anything suspends. Headers go out with the first streamed bytes, so a notFound() inside a boundary can't turn the 200 into a 404, and a loading.tsx for this route would put the page's own check inside one.

Mutations with Server Functions

Reads belong in Server Components; writes go through Server Functions. A Server Function is an async function marked with "use server" that client code can call: the client sends the arguments in a POST request, the function runs on the server, and the serialized result comes back. You'll also see the older name, Server Actions, which now refers to Server Functions used as form actions or inside transitions.

Put "use server" at the top of a file and every export becomes a Server Function that Client Components can import. Here's a review form backed by one:

app/products/[id]/actions.tsTypeScript
"use server";
 
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { createReview } from "@/lib/products";
 
const ReviewInput = z.object({
  productId: z.string().min(1).max(64),
  rating: z.coerce.number().int().min(1).max(5),
  body: z.string().trim().min(10).max(2000),
});
 
export type ReviewFormState = { error?: string; ok?: boolean };
 
export async function addReview(
  _prevState: ReviewFormState,
  formData: FormData,
): Promise<ReviewFormState> {
  const user = await getCurrentUser();
  if (!user) return { error: "Sign in to leave a review." };
 
  const input = ReviewInput.safeParse({
    productId: formData.get("productId"),
    rating: formData.get("rating"),
    body: formData.get("body"),
  });
  if (!input.success) {
    return { error: "Choose a rating and write at least 10 characters." };
  }
 
  await createReview({ ...input.data, userId: user.id });
  revalidatePath(`/products/${input.data.productId}`);
  return { ok: true };
}
app/products/[id]/review-form.tsxTSX
"use client";
 
import { useActionState } from "react";
import { addReview, type ReviewFormState } from "./actions";
 
const initialState: ReviewFormState = {};
 
export function ReviewForm({ productId }: { productId: string }) {
  const [state, formAction, isPending] = useActionState(addReview, initialState);
 
  return (
    <form action={formAction}>
      <input type="hidden" name="productId" value={productId} />
      <label>
        Rating
        <select name="rating" defaultValue="5">
          {[5, 4, 3, 2, 1].map((n) => (
            <option key={n} value={n}>
              {n}
            </option>
          ))}
        </select>
      </label>
      <label>
        Review
        <textarea name="body" required minLength={10} maxLength={2000} />
      </label>
      <button type="submit" disabled={isPending}>
        {isPending ? "Posting..." : "Post review"}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.ok && <p>Thanks, your review is live.</p>}
    </form>
  );
}

useActionState threads the previous state through each call and exposes isPending for the button, and because the action is a Server Function, React can show its response even before the page finishes hydrating. revalidatePath re-renders the product page, and Next.js sends the fresh UI back in the same response, so the new review appears without a second request. Render the form below the reviews with <ReviewForm productId={id} />; getCurrentUser and createReview live in your server-only auth and data modules.

Treat every Server Function as a public endpoint

Every Server Function is reachable with a plain POST request: its ID sits in your client JavaScript, and anyone can call it with any arguments, no form required. So:

  • Authenticate and authorize inside the function. Showing the form only to signed-in users isn't a security boundary. Check the session, then the user's permission for that specific resource.
  • Validate at runtime. TypeScript types are gone at runtime, and the hidden productId field is as editable as the textarea. Zod checks the shape; authorization checks that the caller may act on that ID.
  • Return only what the UI needs. Return values are serialized like props: send a status, not the row you wrote.

Next.js adds its own protection: Server Functions accept only POST, and requests whose Origin doesn't match the host are rejected. That stops cross-site request forgery, not a signed-in user passing someone else's IDs. Keep reads out of them, too: Next.js dispatches them one at a time per client and can't cache their results. The Next.js data security guide covers the rest.

Choosing between server and client

Common mistakes

  • "use client" on a layout. The layout and its imports become client code, and the layout loses metadata exports and server-side fetching. Pages, passed in as children, stay on the server, but the shared shell ships as JavaScript.
  • Giant client boundaries. One widget's state doesn't justify a client page. Move the directive down to the widget and pass server content through children.
  • Misplaced providers. Fixing a createContext error by marking the layout as client repeats the first mistake. Give each provider a small client file.
  • Server code in client files. The build fails with a confusing Node.js error or, worse, quietly ships logic you meant to keep private. Guard those modules with server-only.
  • Fetching in useEffect out of habit. If an API route exists only to feed one Client Component, fetch in a Server Component parent and pass the data down as props.

Server or client: the decision table

I start every component on the server and move it only when it needs something from a Client row:

The component needs toSideWhy
Fetch data or query a databaseServerRuns next to the data, no API route needed
Use secrets such as API keys or tokensServerThey never leave the server
Render content that doesn't respond to inputServerShips no component JavaScript
Use a large library to parse or format contentServerThe library stays out of the bundle
Handle events like onClick or onChangeClientEvent handlers need JavaScript in the browser
Hold state or run effectsClientuseState and useEffect only work in Client Components
Use browser APIs like window or localStorageClientThey only exist in the browser
Provide or read React contextClientPut the provider in a small client file
Wrap server content in interactive UIBothClient wrapper, server content passed as children

Key takeaways

  • Server Components are the default: they run on the server and ship no component code. Add "use client" only for state, effects, event handlers, or browser APIs.
  • "use client" marks a module boundary, and everything the file imports becomes client code. Keep it on small interactive leaves.
  • Pass Server Components into Client Components as children or other props, and give providers their own small client files.
  • Props crossing the boundary must be serializable and are public: send only what the UI needs, and guard server modules with server-only.
  • Fetch in Server Components, parallelize with Promise.all, preload with cache, and stream slow sections with <Suspense>.
  • Treat Server Functions as public POST endpoints: authenticate, authorize, and validate every input.

Server Components aren't a replacement for the client; they're a better default. Once you see "use client" as a line in your import graph rather than a label on a component, most decisions get mechanical: start on the server, push interactivity to the leaves, and send plain data across the boundary. The Next.js guide to Server and Client Components covers what I skipped.