Server and Client Components in Next.js


"use client" is often treated as an instruction to make something work. It is more useful to read it as a boundary declaration: this file, and the imports it needs at runtime, become part of the browser-facing application. Everything above that boundary can stay on the server, fetch secure data, and render without becoming client JavaScript.
That makes component placement an architectural choice. The question is not which component is "better." The question is which environment owns its responsibility.
The Boundary Is About Responsibility
Server Components are right for data access, secure configuration, rendering content, and assembling a route. Client Components are right for state, event handlers, browser APIs, effects, and libraries that require the DOM.
// Server Component: no "use client"
export default async function BillingPage() {
const invoices = await getInvoicesForCurrentAccount();
return <InvoiceFilters initialInvoices={invoices} />;
}// InvoiceFilters.tsx
"use client";
export function InvoiceFilters({ initialInvoices }: Props) {
const [query, setQuery] = useState("");
// Browser-owned filtering and event handling live here.
}The server prepares a serializable view model. The client owns only the interaction. That split protects credentials, keeps the initial route lighter, and makes it obvious where state belongs.
What Can Cross the Boundary
Props flowing from a Server Component to a Client Component must be serializable. Plain objects, strings, numbers, booleans, arrays, and dates represented as strings are safe. Functions, database handles, class instances, request objects, and secrets are not.
// Good: pass data
<InvoiceFilters initialInvoices={invoices.map(toInvoiceView)} />
// Not valid: server-only behaviour cannot cross into the client
<InvoiceFilters database={databaseConnection} />This constraint is helpful. It forces a clean interface between server work and browser work. If a client component needs a server action, expose a narrow action with validated input rather than handing the component unrestricted access to a service layer.
Serialize a View Model, Not Your Server Model
A client component should receive the smallest data shape it needs to render and interact. This prevents accidental coupling to database details and keeps sensitive fields on the server.
Keep Client Islands Small
The largest performance mistake is moving an entire page or layout to the client because one descendant needs a click handler. Instead, isolate the interactive leaf.
Find the smallest client boundary
Use this explorer to reinforce the design rule: data access stays on the server, while state and events belong in a focused client island.
Choose where this work belongs:
Server Components can read data and secrets before sending a rendered result to the browser. They cannot attach browser event handlers.
export default async function Page() {
const data = await getData();
return <Filters initialData={data} />;
}This is not an argument for avoiding Client Components. Interactive products need them. The goal is to make the boundary intentional, inspectable, and as close as possible to the interaction that requires it.
Use Composition Instead of Escalation
Sometimes a client provider appears to force an entire subtree into the client. Composition avoids that escalation. A Server Component can render a Client Component that receives server-rendered children:
export default function Page() {
return (
<ClientThemeProvider>
<ServerRenderedReport />
</ClientThemeProvider>
);
}The provider manages browser state, while ServerRenderedReport can remain server-rendered. This pattern lets you keep broad layout content on the server even when a smaller interactive wrapper is necessary.
A Review Checklist
Before adding "use client", ask:
- Does this component need state, an event handler, an effect, or a browser API?
- Can that need be isolated in a smaller descendant?
- Are all props crossing the boundary serializable and free of secrets?
- Is the component importing a large client-only dependency unnecessarily?
- Would a server action or a route handler give the client a narrower interface?
Key Takeaways
- Server and Client Components divide responsibilities, not capabilities.
- Keep data access and secure logic on the server by default.
- Pass small serializable view models into interactive client islands.
- Place
"use client"at the smallest component that truly needs browser behaviour. - Use composition to avoid turning broad route shells into client bundles.
Make the Boundary Observable
The boundary has both a security and performance cost. Inspect the client bundle after moving a component behind "use client", especially when it imports a charting library, editor, analytics SDK, or provider stack. A tiny interactive control can accidentally pull a large dependency graph into the browser if it imports a convenience module from the wrong side of the boundary.
Keep server-only code in modules that a Client Component cannot import. Database clients, private environment variables, filesystem access, privileged SDKs, and authorization helpers belong behind server modules and routes. A build error from an accidental import is useful; structure folders and imports so it happens early rather than relying on a reviewer to notice a secret-shaped prop.
Design the serialized view model
Props from a Server Component to a Client Component must be serializable. Treat that requirement as a helpful API-design constraint. Instead of passing a database entity, pass the exact display model the interactive island needs: stable IDs, formatted labels, booleans, and bounded option lists. Re-fetch or invoke a Server Action for a consequential mutation rather than passing a broad object graph to the browser “just in case.”
// Server Component
<ProjectFilters
options={projects.map((project) => ({ id: project.id, label: project.name }))}
initialSelection={selectedProjectId}
/>This makes data exposure reviewable. It also decouples the UI island from an ORM shape, avoids serialization surprises around dates and class instances, and reduces unnecessary payload.
Test both sides of the seam
Unit-test client islands as interactions: focus management, keyboard behaviour, optimistic states, validation feedback, and recovery. Test server components, Actions, and Route Handlers for authorization and data shaping. Then include a few browser-level tests that prove the composed route works with real serialization and navigation. A component that passes isolated tests can still fail when an invalid prop crosses the server/client seam or a cached server result no longer matches client state.
When a page feels slow, do not reflexively turn more of it into a Client Component. Check whether the server data is sequential, whether a Suspense boundary can stream an independent region, whether a client dependency is oversized, and whether the browser actually needs to own the interaction. The smallest intentional boundary is often both the safer and faster design.
Audit the import graph after a boundary change
When an island becomes client-side, inspect its direct and transitive imports. Move formatting, static data, and server-only helpers back across the seam when they do not participate in interaction. Then test a no-JavaScript or slow- JavaScript visit for the route’s essential content. The page should remain informative even if an enhancement arrives late; interaction should add capability rather than become the only way to understand the screen.
Record bundle-size changes in the pull request when a boundary moves. A small measurement makes the performance trade-off concrete and gives later refactors a baseline to preserve.