Next.js App Router server vs client component decision
Decide between server components and `'use client'` at the leaf, balancing data-fetch latency, streaming, and the `next build` cost that often forces you back across the boundary.
Three months into the App Router and one decision still bites: when do I add 'use client', when do I keep a file as a Server Component, and where exactly does the boundary cost me? The official rule of thumb is "everything is a Server Component until you need browser-only APIs." That advice is correct, but it under-specifies the operational question: at what point in the tree do I draw the line?
This article walks five commits on a tiny dashboard app that pushes the answer out into concrete numbers. The app fetches stats and activity on the server, hands data to client leaves as props, streams a slow query with <Suspense>, and ends with a Server Action that replaces a hand-written API route. After each commit, bun run build prints a route table that tells you what crossed the boundary and what stayed home.
Here is the rule the rest of the article defends: place 'use client' as close to the interactive widget as possible, and pass server data DOWN into that widget as props. Putting the directive at the top of a route or a layout reverses the polarity. Every descendant becomes a Client Component, and the server's data-fetch advantage evaporates into a browser fetch waterfall.
Lesson 1: server component baseline, ship zero JS for the data
The default is the strongest position. A page in app/ without 'use client' is a Server Component. It can be async, it can await data on the server, and the React it renders is serialized into HTML. The browser receives the final UI plus a small React runtime, but nothing of your fetch logic, your data layer, or the component code itself crosses the boundary.
In this lesson, app/page.tsx awaits fetchStats() and lists the result. The data layer lives in app/lib/data.ts and is gated by the server-only package: importing it from a Client Component fails the build. That import is the cheapest safety net you have against accidentally bundling a database driver into the browser.
// app/page.tsx
import { fetchStats } from "./lib/data";
export default async function HomePage() {
const stats = await fetchStats();
return (
<main>
<h1>Dashboard</h1>
<ul>
{stats.map((s) => (
<li key={s.id}>
<strong>{s.label}:</strong> {s.value} {s.unit}
</li>
))}
</ul>
</main>
);
}
Running bun run build after this commit shows / at 139 B of route-specific JavaScript, on top of 99.9 kB of shared chunks. Those 139 bytes are essentially the RSC payload pointer; the stats values themselves are baked into the HTML. Try it: commit 483cea4.
Lesson 2: leaf-level 'use client', the smallest interactive island
The moment you need useState, useEffect, an event handler, or a browser-only API, you cross into Client Component territory. The interesting question is where you cross. A cheap mistake is to drop 'use client' at the top of the page file. The disciplined move is to extract the interactive widget into its own file and put 'use client' there.
This lesson adds app/components/refresh-counter.tsx, a button with a useState counter, and renders it inside the still-server app/page.tsx. The boundary lives at the import. RefreshCounter is a Client Component, so its file and its deps are bundled for the browser. Everything around it remains server-rendered.
// app/components/refresh-counter.tsx
"use client";
import { useState } from "react";
export function RefreshCounter() {
const [n, setN] = useState(0);
return (
<button onClick={() => setN((v) => v + 1)}>
Refresh ({n})
</button>
);
}
Build output after this commit: / jumps from 139 B to 481 B of route JS. That 342 B delta is the refresh counter island plus its serialization wiring. The stats list still ships zero JS. It sits in the HTML, the way prerendered content should. Try it: commit 7a26ceb.
Compare against the alternative. Had I added 'use client' to app/page.tsx, every descendant including the stats list, the heading, and the layout slots, would have hydrated. The shared bundle would have grown to include the stats data layer (or fail the server-only check). Leaf-level placement is not a stylistic preference; it is the only choice that preserves the data-on-server win.
Lesson 3: pass server-fetched data down, feed props not endpoints
A Client Component cannot await async data the way a Server Component can. The na\u00efve workaround is to fetch from the browser with useEffect plus fetch('/api/...'). Now you have a loading flicker, two round-trips, and an API route you have to write, secure, and version.
App Router instead asks you to fetch in the server parent and pass the data DOWN as props. The serialization happens automatically. Next.js converts the data into the RSC payload, the browser receives it, and the client leaf renders synchronously with no loading state.
// app/page.tsx (excerpt)
const [stats, activity] = await Promise.all([
fetchStats(),
fetchRecentActivity(),
]);
return (
<main>
{/* ... */}
<ActivityFeed items={activity} />
</main>
);
// app/components/activity-feed.tsx
"use client";
import { useState } from "react";
export function ActivityFeed({ items }: { items: Activity[] }) {
const [expanded, setExpanded] = useState(false);
return (
<ul>
{items.slice(0, expanded ? items.length : 2).map((a) => (
<li key={a.id}>{a.user} {a.action}</li>
))}
<button onClick={() => setExpanded((v) => !v)}>
{expanded ? "collapse" : "expand"}
</button>
</ul>
);
}
This is the unique strength of RSC over the SPA shape you might know from Create React App or a typical Vite plus React project: the server fetch and the client UI share a tree, and props carry data across the boundary without you writing an endpoint. There is one subtlety. Props must be serializable (no functions, no Symbols, no class instances), but plain JSON shapes pass through fine. Try it: commit 62644f4.
Bundle size at / ticks up to 714 B, which is the activity feed's client code including the toggle state. The stats list is still pure HTML; only the interactive parts paid.
Lesson 4: stream the slow query with <Suspense>
The activity fetch in this demo is artificially slow (600 ms). Without intervention, awaiting it in app/page.tsx blocks the entire HTML response. The fast fetchStats (40 ms) waits behind the slow query, and the user sees a blank page for 600 ms.
<Suspense> is the fix. Wrap a server subtree in <Suspense fallback={...}> and Next.js streams the rest of the HTML immediately, then streams the subtree once it resolves. The fallback fills the slot in the meantime; no client-side spinner library required.
// app/page.tsx (excerpt)
<Suspense fallback={<p>Loading activity...</p>}>
<ActivityFeedServer />
</Suspense>
ActivityFeedServer is a Server Component that owns the slow fetch. It is the suspension boundary. The Client Component inside (ActivityFeed) receives data only once the server fetch resolves, but that resolution happens after the rest of the page has already streamed to the browser.
// app/components/activity-feed-server.tsx
import { fetchRecentActivity } from "../lib/data";
import { ActivityFeed } from "./activity-feed";
export async function ActivityFeedServer() {
const items = await fetchRecentActivity();
return <ActivityFeed items={items} />;
}
This lesson also adds app/loading.tsx, the segment-level fallback shown during the initial navigation while the route is preparing. Where <Suspense> is granular (specific subtree), loading.tsx is the whole-route safety net. Use both in different roles. Try it: commit 83c07ba.
There is a measurable user-experience win here even with no JavaScript on the client. A 600 ms blocking response becomes a roughly 40 ms time-to-first-byte plus a streamed activity block. Lab testing on a throttled 3G profile (Chrome DevTools) shows Largest Contentful Paint shift from about 1.4 s to about 0.7 s for this route under streaming, because the stats list paints during the 600 ms the activity query is still resolving on the server. See the react.dev Suspense reference for the full semantics.
Lesson 5: Server Action from a 'use client' form, no API route, no fetch
This final lesson pushes the server-vs-client boundary one more place: form submission. A na\u00efve approach is <form> plus useState plus fetch('/api/notes', { method: 'POST' }). Three pieces of code (client handler, API route, server logic) that have to stay in sync, plus a JSON schema you write twice.
Server Actions collapse all of that. A file marked 'use server' exports functions that look local but compile into a secure RPC endpoint. A client form passes the action straight into <form action={...}>, and Next.js does the transport.
// app/actions/notes.ts
"use server";
import "server-only";
export async function addNote(formData: FormData) {
const text = String(formData.get("note") ?? "").trim();
if (!text) return { ok: false, error: "empty" } as const;
await persist(text);
return { ok: true, count: notesCount() } as const;
}
// app/components/note-form.tsx
"use client";
import { useTransition } from "react";
import { addNote } from "../actions/notes";
export function NoteForm() {
const [isPending, startTransition] = useTransition();
return (
<form action={(fd) => startTransition(() => addNote(fd))}>
<input name="note" />
<button disabled={isPending}>
{isPending ? "saving..." : "save"}
</button>
</form>
);
}
The interesting line is import { addNote } from "../actions/notes" from a Client Component. That import does not drag server code into the browser bundle. Next.js detects the 'use server' directive and replaces it with a generated client stub that POSTs to a hidden endpoint with a signed reference. You write one function; the framework generates the wire. Try it: commit 0169c31.
Final build output: / is 1.3 kB of route JS. Every client island we added (refresh counter, activity feed expand-toggle, note form pending state), plus the Server Action client stub. Across five lessons, route JS went from 139 B to 1.3 kB, and only the explicitly-interactive widgets contributed. The stats list, the activity HTML, and the form's submit logic all stayed on the server.
For a deeper look at the Server Action transport and security model, the Server Actions reference walks through the contract.
When to break the leaf-first rule
Three cases force the boundary higher up the tree:
- Browser-only state spanning multiple widgets: e.g. a Zustand store driving five interactive cards. The store needs a Client Component ancestor wrapping all of them. Push the boundary up to the smallest common parent, not to the page root.
- Third-party providers:
<ThemeProvider>,<QueryClientProvider>,<JotaiProvider>from libraries that rely on React Context. Those providers themselves must be Client Components. Best practice: define a thin client wrapper component, then keep your route a Server Component that renders<ClientProviders>{children}</ClientProviders>. - Browser API at the top level: a hook that calls
window.matchMediafor app-wide theming. Same fix. Use a small client wrapper at the layout level, not the entire layout flipped to client.
In every case, the move is to push boundaries DOWN, not pull boundaries up. The official server and client components guide makes the same point in reverse. When porting from Pages Router, the work is identifying which components actually need 'use client', not blanket-applying the directive.
Repository
Full source at https://github.com/vytharion/nextjs-app-router-server-vs-client-component-decision.
Walk the commits in order to follow each lesson:
- Lesson 1 at
483cea4: server component baseline; route JS = 139 B. - Lesson 2 at
7a26ceb: leaf'use client'refresh counter; route JS = 481 B. - Lesson 3 at
62644f4: pass server data as props; route JS = 714 B. - Lesson 4 at
83c07ba:<Suspense>streaming plusloading.tsx. - Lesson 5 at
0169c31: Server Action from a client form; route JS = 1.3 kB.
git clone https://github.com/vytharion/nextjs-app-router-server-vs-client-component-decision.git
cd nextjs-app-router-server-vs-client-component-decision
bun install
bun run dev
# open http://localhost:3000
Check out any lesson SHA to see the app at that exact step, then run bun run build and read the route table at the bottom of the output. The Size column is the per-route JavaScript; the First Load JS column is total including shared chunks. Watching that Size column move from 139 B to 1.3 kB across five commits is the single best feedback loop for understanding the boundary.
Conclusion
The decision rule is small. Keep the boundary at the leaf. Pass server data down as props. Wrap slow server work in <Suspense>. Replace fetch plus API route pairs with Server Actions. The framework rewards discipline. The build output shows you exactly what you saved.
Clone the repo, walk the commits, and try the inverse experiment: move 'use client' to app/page.tsx for one commit. Build it. Then move it back to the leaves and build again. The diff in route JS is the cost of getting the boundary wrong, and the size of that cost is the size of the page.