All topics

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

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.
}

A single piece of interactivity pulls the whole subtree and its dependencies into the client bundle.

Prefer

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>
  );
}

Only the tab-switching logic ships. The chart and its dependencies never enter the client graph.

Request waterfalls

Avoid

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

Three independent requests run in sequence. Latency is the sum instead of the maximum.

Prefer

tsx
const [user, posts, stats] = await Promise.all([
  getUser(id),
  getPosts(id),
  getStats(id),
]);

// Total = the slowest single request

Independent work runs concurrently. Only genuinely dependent calls stay sequential.

Protecting server-only modules

Avoid

ts
// 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

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) { /* ... */ }

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.

Download
.claude/skills/nextjs-server-client/SKILL.md

Drop 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