6 min read
Server & Client Components
Where the `use client` boundary belongs in Next.js App Router, how to keep server-only code off the wire, and fetching without waterfalls.
Principles
Push `use client` to the leaves
The directive is a boundary, not a file flag: every module a client component imports joins the client bundle. One `use client` near the root drags your entire component tree across with it.
Server components can be passed as children to client components
This is the escape hatch that makes leaf-level boundaries practical. Children arrive as already-rendered output rather than through the client module graph, so an interactive wrapper never forces its contents client-side.
Fetch in parallel unless there is a real dependency
Sequential awaits create a waterfall where each request waits for the last. If the second request doesn't need the first one's result, `Promise.all` turns the total cost into the slowest request rather than the sum.
Keep secrets structurally unreachable, not just unused
An API key referenced in a module that a client component imports ends up in the browser bundle. `server-only` turns that mistake into a build error rather than a security incident.
Stream slow content instead of blocking the page
A Suspense boundary around a slow section lets everything else paint immediately. Users get a usable page while the expensive part resolves.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Boundary placement
Avoid
"use client"; // at the top of the page
import { HeavyChart } from "./HeavyChart";
import { IconSet } from "@phosphor-icons/react";
export default function Dashboard() {
const [tab, setTab] = useState("overview");
// One `useState` just sent the chart library, the icon set,
// and every child component to the browser.
}A single piece of interactivity pulls the whole subtree and its dependencies into the client bundle.
Prefer
// page.tsx — stays a Server Component
import { Tabs } from "./Tabs"; // "use client" lives here
import { HeavyChart } from "./HeavyChart"; // stays on the server
export default function Dashboard() {
return (
<Tabs>
{/* Rendered on the server, passed in as output. */}
<HeavyChart data={await getChartData()} />
</Tabs>
);
}Only the tab-switching logic ships. The chart and its dependencies never enter the client graph.
Request waterfalls
Avoid
const user = await getUser(id);
const posts = await getPosts(id); // waited for user pointlessly
const stats = await getStats(id); // waited for posts pointlessly
// Total = user + posts + statsThree independent requests run in sequence. Latency is the sum instead of the maximum.
Prefer
const [user, posts, stats] = await Promise.all([
getUser(id),
getPosts(id),
getStats(id),
]);
// Total = the slowest single requestIndependent work runs concurrently. Only genuinely dependent calls stay sequential.
Protecting server-only modules
Avoid
// lib/payments.ts — nothing stops a client import
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY;
export async function charge(amount: number) { /* ... */ }One accidental import from a client component and the secret is in the browser bundle. Nothing warns you.
Prefer
import "server-only"; // importing this from the client fails the build
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY;
export async function charge(amount: number) { /* ... */ }The mistake becomes a build error, caught in CI rather than in a security review.
Review checklist
What I look for when reviewing a pull request that touches this area.
- `use client` appears only on genuinely interactive leaf components.
- Server components are passed as `children` rather than imported by client components.
- Independent fetches are wrapped in `Promise.all`.
- Every module touching secrets imports `server-only`.
- Slow sections are wrapped in Suspense with a real fallback.
- Bundle output was checked after the change, not assumed.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/nextjs-server-client/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: nextjs-server-client
description: >-
Next.js App Router server and client component boundaries: leaf-level use
client, passing server components as children, parallel data fetching,
server-only modules, and Suspense streaming. Use when building App Router
pages.
argument-hint: "[route or component]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Server & Client Components
Apply when building or reviewing App Router code. Before adding `use client`, check whether the interactivity can be isolated into a smaller leaf component with the rest passed through as children.
## Rules
1. **Push `use client` to the leaves** — The directive is a boundary, not a file flag: every module a client component imports joins the client bundle. One `use client` near the root drags your entire component tree across with it.
2. **Server components can be passed as children to client components** — This is the escape hatch that makes leaf-level boundaries practical. Children arrive as already-rendered output rather than through the client module graph, so an interactive wrapper never forces its contents client-side.
3. **Fetch in parallel unless there is a real dependency** — Sequential awaits create a waterfall where each request waits for the last. If the second request doesn't need the first one's result, `Promise.all` turns the total cost into the slowest request rather than the sum.
4. **Keep secrets structurally unreachable, not just unused** — An API key referenced in a module that a client component imports ends up in the browser bundle. `server-only` turns that mistake into a build error rather than a security incident.
5. **Stream slow content instead of blocking the page** — A Suspense boundary around a slow section lets everything else paint immediately. Users get a usable page while the expensive part resolves.
## Patterns
### Boundary placement
**Avoid** — A single piece of interactivity pulls the whole subtree and its dependencies into the client bundle.
```tsx
"use client"; // at the top of the page
import { HeavyChart } from "./HeavyChart";
import { IconSet } from "@phosphor-icons/react";
export default function Dashboard() {
const [tab, setTab] = useState("overview");
// One `useState` just sent the chart library, the icon set,
// and every child component to the browser.
}
```
**Prefer** — Only the tab-switching logic ships. The chart and its dependencies never enter the client graph.
```tsx
// page.tsx — stays a Server Component
import { Tabs } from "./Tabs"; // "use client" lives here
import { HeavyChart } from "./HeavyChart"; // stays on the server
export default function Dashboard() {
return (
<Tabs>
{/* Rendered on the server, passed in as output. */}
<HeavyChart data={await getChartData()} />
</Tabs>
);
}
```
### Request waterfalls
**Avoid** — Three independent requests run in sequence. Latency is the sum instead of the maximum.
```tsx
const user = await getUser(id);
const posts = await getPosts(id); // waited for user pointlessly
const stats = await getStats(id); // waited for posts pointlessly
// Total = user + posts + stats
```
**Prefer** — Independent work runs concurrently. Only genuinely dependent calls stay sequential.
```tsx
const [user, posts, stats] = await Promise.all([
getUser(id),
getPosts(id),
getStats(id),
]);
// Total = the slowest single request
```
### Protecting server-only modules
**Avoid** — One accidental import from a client component and the secret is in the browser bundle. Nothing warns you.
```ts
// lib/payments.ts — nothing stops a client import
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY;
export async function charge(amount: number) { /* ... */ }
```
**Prefer** — The mistake becomes a build error, caught in CI rather than in a security review.
```ts
import "server-only"; // importing this from the client fails the build
export const STRIPE_SECRET = process.env.STRIPE_SECRET_KEY;
export async function charge(amount: number) { /* ... */ }
```
## Review checklist
- [ ] `use client` appears only on genuinely interactive leaf components.
- [ ] Server components are passed as `children` rather than imported by client components.
- [ ] Independent fetches are wrapped in `Promise.all`.
- [ ] Every module touching secrets imports `server-only`.
- [ ] Slow sections are wrapped in Suspense with a real fallback.
- [ ] Bundle output was checked after the change, not assumed.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim