The App Router's default is the part people miss: every component is a Server Component until you say otherwise. That default is doing a lot of work for you, and one misplaced directive can undo it.
The boundary is a tree, not a file
"use client" does not mark one file as client-side. It marks an entry point. Everything that file imports becomes part of the client bundle too.
// app/dashboard/page.tsx — Server Component
import { Chart } from "./chart"; // client
import { formatCurrency } from "@/lib/format"; // pulled into the client bundleSo the goal is to push the directive as far down the tree as possible.
A concrete refactor
Here is the version that ships too much:
"use client";
import { useState } from "react";
export function PostList({ posts }: { posts: Post[] }) {
const [query, setQuery] = useState("");
const visible = posts.filter((p) => p.title.includes(query));
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{visible.map((post) => (
<PostCard key={post.id} post={post} />
))}
</>
);
}PostCard is now a Client Component whether it needs to be or not. Move the state into the smallest possible island and let the server render the rest:
// server
export function PostList({ posts }: { posts: Post[] }) {
return (
<>
<SearchInput />
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</>
);
}SearchInput writes the query to the URL, the page reads searchParams, and filtering happens on the server against the database instead of against an array you had to send over the wire.
Rules that have held up
- Data fetching belongs in Server Components. Always.
useStatefor UI state is a fine reason to go client.useStatefor server state usually is not.- Passing a Server Component as
childrento a Client Component keeps it on the server. - Anything you pass across the boundary must be serializable — no functions, no class instances, no
Datemethods you rely on.
// This works: Sidebar stays a Server Component
<ClientShell>
<Sidebar />
</ClientShell>The measurement that matters
Run a production build and read the route table. If a mostly-static page reports a large First Load JS, you have a boundary in the wrong place. That number is the honest scoreboard.