AI Reading
Quick summary of this article
Understand React Server Components in Next.js 16: when to add 'use client', how to compose server and client trees, passing data across the boundary and avoiding the classic pitfalls.
- Every component in the App Router is a Server Component by default and never ships JavaScript to the browser.
- Add
"use client"only to the leaf components that need state, effects, event handlers or browser APIs. - Server Components can render Client Components, and can pass them serialisable props — but functions and class instances cannot cross the boundary.
- Importing a Server Component into a Client Component silently turns it into a Client Component, which is the most common performance mistake.
Complete Next.js 16 Series — Module 5 of 12
This is the module that separates developers who use Next.js from developers who understand it. React Server Components are not a Next.js feature bolted on top of React — they are the rendering model the App Router is built on.
The Default: Everything Is a Server Component
When you write a component in src/app/ without any directive, it is a Server Component:
export default async function ProductList() {
const products = await db.product.findMany();
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
This component can:
- Be
asyncandawaitdata directly. - Read the database, filesystem or private environment variables.
- Render on the server only — zero bytes of it are sent to the browser.
It cannot:
- Use
useState,useEffectoruseRef. - Attach
onClickor any event handler. - Read
window,localStorageordocument.
The Opt-In: "use client"
Add the directive when you need interactivity:
"use client";
import { useState } from "react";
export default function QuantityPicker() {
const [qty, setQty] = useState(1);
return (
<div className="flex items-center gap-3">
<button onClick={() => setQty((q) => Math.max(1, q - 1))} aria-label="Decrease">
−
</button>
<span className="tabular-nums">{qty}</span>
<button onClick={() => setQty((q) => q + 1)} aria-label="Increase">
+
</button>
</div>
);
}
"use client" does not mean “this only runs in the browser”. The component still renders on the server to produce HTML for the first paint. It means: this component and its imports are part of the client bundle and will hydrate.
The directive is a file-level boundary. Everything imported by that file joins the client bundle too. This is why putting
"use client"at the top of alayout.tsxis usually a mistake.
Composition: Server Data, Client Behaviour
The correct pattern is to keep the client component as small and leaf-like as possible, and let the server component own the data.
// src/app/products/[id]/page.tsx — Server Component
import { AddToCart } from "@/components/add-to-cart";
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await db.product.findUniqueOrThrow({ where: { id } });
return (
<div className="grid gap-8 md:grid-cols-2">
<img src={product.image} alt={product.name} className="rounded-xl" />
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="mt-2 text-2xl">${product.price}</p>
{/* only this island is interactive */}
<AddToCart productId={product.id} price={product.price} />
</div>
</div>
);
}
// src/components/add-to-cart.tsx — Client Component
"use client";
import { useState } from "react";
export function AddToCart({ productId, price }: { productId: string; price: number }) {
const [added, setAdded] = useState(false);
return (
<button
onClick={() => {
setAdded(true);
void fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId }),
});
}}
className="mt-6 rounded-md bg-brand-600 px-6 py-3 text-white"
>
{added ? "Added ✓" : `Add to cart — $${price}`}
</button>
);
}
The whole product page — price formatting, layout, image — costs nothing in the client bundle. Only the button ships.
Passing a Server Component Through a Client Component
You cannot import a Server Component into a Client Component and keep it server-rendered. But you can pass it as children, because children are rendered before the client component sees them:
// client-shell.tsx
"use client";
import { useState } from "react";
export function Collapsible({ title, children }: { title: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((o) => !o)} aria-expanded={open}>
{title} {open ? "▾" : "▸"}
</button>
{open && <div>{children}</div>}
</div>
);
}
// a Server Component page composes it
import { Collapsible } from "@/components/collapsible";
import { RevenueChart } from "@/components/revenue-chart"; // Server Component, queries the DB
export default function DashboardPage() {
return (
<Collapsible title="Revenue">
<RevenueChart /> {/* still a Server Component */}
</Collapsible>
);
}
RevenueChart runs on the server and its output is streamed to the client. This composition pattern is what the Next.js docs call “moving client components down the tree”, and it is the single highest-leverage refactor in most Next.js apps.
What Cannot Cross the Boundary
Props passed from a Server Component to a Client Component must be serialisable:
- ✅ Strings, numbers, booleans,
null,undefined - ✅ Plain objects, arrays,
Date,Map,Set,BigInt - ✅ Promises (React 19 resolves them on the client)
- ✅ JSX elements (children)
- ❌ Functions that are not Server Actions
- ❌ Class instances,
Symbol,WeakMap - ❌ Anything holding a database connection or secret
Passing a function throws a cryptic serialisation error at build or runtime. The fix is either to make it a Server Action (Module 7) or to move the call into the client component.
Caching Client Fetches to Avoid Waterfalls
Prefer server-side data. When a client component genuinely must fetch, block the request on the server by passing data down — or fetch in parallel:
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function LivePrice({ symbol }: { symbol: string }) {
const { data, isLoading } = useSWR(`/api/price/${symbol}`, fetcher, {
refreshInterval: 5000,
revalidateOnFocus: true,
});
if (isLoading) return <span className="text-neutral-400">—</span>;
return <span className="tabular-nums">{data.price.toFixed(2)}</span>;
}
Rule of thumb: fetch in a Server Component for content (SSR, cacheable, SEO-friendly). fetch in a Client Component for live or user-specific data (websockets, polling, dashboards behind auth).
A Decision Checklist
Work through these questions in order for each component:
- Does it need state, effects, refs, event handlers or a browser API? → Client Component.
- Does it pass such things to its children? → keep it a Client Component but pass server children through
children. - Otherwise → Server Component.
- Is the client component large? → push
"use client"to the smallest leaf.
A useful measurement: npm run build prints a First Load JS number per route. If that number jumps after a change, a heavy module crossed the boundary. Adding "use client" to a component that imports a date library, a chart library or a form library is the usual culprit.
Module 5 Recap
- Server Components are the default and ship zero JavaScript.
"use client"marks a hydration boundary and pulls the whole import graph along with it.- Client Components still server-render their initial HTML — they just also hydrate.
- Compose by passing Server Components as
childreninto Client Components. - Props crossing the boundary must be serialisable; functions must be Server Actions.
Next up — Module 6: data fetching and caching — fetch options, use cache, cacheLife, and the Next.js 16 invalidation APIs.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 4: Dynamic Routes & SEO Metadata
- Next: Module 6: Data Fetching & Caching
