React Server Components are no longer the new thing. They are the default way Next.js apps are built, and we have shipped enough of them now to have strong opinions about the mistakes that keep recurring. This post is the mental model we hand new engineers when they join a Server Components codebase, plus the patterns we reach for once they have the basics.
The mental model: server by default, client on the leaves
The single sentence version: render on the server, opt into the client only where you need interactivity. That sounds obvious. It is not how most teams actually structure their apps, because muscle memory from the SPA era pushes you to make everything a client component.
A useful way to think about it: your component tree is a server tree with islands of client components. The islands should be as small as possible and as deep in the tree as possible. The further you push 'use client' toward the leaves, the more of your app gets the server component benefits: zero client JS for that code, direct data access, faster initial render.
What server components can do that client components cannot
Server components run once, on the server, and return serialized output. That gives you four superpowers:
- Async by default. A server component can be an async function. You can
await fetch,await db.query,await getSessiondirectly in the component body. - Direct access to server resources. Database connections, file system, secrets in environment variables, internal services on a private network.
- No client bundle cost. The component's code never ships to the browser. A 200KB Markdown parser used only at render time costs zero kilobytes on the client.
- No hydration cost. The output is HTML. No JS needs to run to make it interactive, because it is not interactive.
// app/posts/[slug]/page.tsx
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import { LikeButton } from "./like-button";
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
<LikeButton postId={post.id} initialCount={post.likeCount} />
</article>
);
}
That entire page renders on the server. The only JS that ships to the browser is whatever LikeButton needs.
What client components are for
Client components are not "the bad ones." They are the right tool for anything that needs to react to the user or the browser:
useState,useReducer,useContext, any hook with stateuseEffectand lifecycle behavior- Event handlers (
onClick,onChange,onSubmit) - Browser APIs (
window,localStorage,IntersectionObserver, drag and drop) - Third party libraries that touch the DOM directly
// app/posts/[slug]/like-button.tsx
"use client";
import { useState, useTransition } from "react";
export function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
const [count, setCount] = useState(initialCount);
const [pending, start] = useTransition();
return (
<button
disabled={pending}
onClick={() => {
start(async () => {
setCount((c) => c + 1);
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
});
}}
>
Like ({count})
</button>
);
}
The 'use client' directive marks the file as a client entry point. Everything imported from this file is also bundled for the client. That is the rule worth tattooing.
The client boundary pattern
The trick that takes a Server Components app from "fine" to "fast" is keeping the client subtree small by passing server rendered content into a client wrapper as children. A client component can render children without those children becoming client components themselves.
// app/dashboard/page.tsx
import { Sidebar } from "@/components/sidebar";
import { ExpensiveServerComponent } from "@/components/expensive";
import { CollapsiblePanel } from "@/components/collapsible-panel";
export default function Dashboard() {
return (
<CollapsiblePanel> {/* client component, holds open/closed state */}
<ExpensiveServerComponent /> {/* still a server component */}
</CollapsiblePanel>
);
}
CollapsiblePanel owns the open/closed state and the toggle handler. ExpensiveServerComponent renders on the server and is passed in as a child. The client component never re-renders the server content because it is already a fully formed React element by the time it arrives.
This pattern unlocks a lot. It is how you put a server rendered list inside a client side filter UI, a server rendered article inside a client side reading progress tracker, or a server fetched dashboard inside a client controlled tab.
The mistakes we keep seeing
After a few dozen RSC code reviews, the same problems show up:
- Making everything a client component. Adding
'use client'to your layout or root page pulls the entire tree into the client bundle. You lose every benefit of server components. Use'use client'at the smallest reasonable scope. - Forgetting
'use client'entirely. You get a confusing error about hooks or event handlers in server components. The fix is the directive, not refactoring the component. - Passing functions across the boundary. Server components cannot pass functions as props to client components (except server actions). The serialization barrier is real. Pass primitive data and let the client component define its own handlers.
- Importing client only libraries in server components. Some libraries reach for
windowat import time. Wrapping their usage in a client component fixes it. - Putting secrets in components that might leak. Anything in a server component is safe. But once you pass it as a prop to a client component, it crosses the network in the RSC payload. Treat that boundary as a public API.
When to break the rule
Defaults are defaults, not laws. We reach for client components more aggressively in three cases:
- Highly interactive surfaces. A rich text editor, a Kanban board, a chart with drilldowns. The interactivity is the product. Server rendering the shell and hydrating into a client app is the right shape.
- Streaming with Suspense. A server component can stream, but if you need fine grained loading states tied to user actions, a client component with a transition is often simpler.
- Client side data that updates frequently. Live presence indicators, real time collaboration, anything driven by WebSockets. The data lives on the client. Fetch it there.
Practical takeaways
- Start every component as a server component. Add
'use client'only when something inside it demands it. - Push the client boundary as deep as possible. Wrap interactive bits, do not wrap the whole page.
- Use the
childrenpattern to keep server rendered content out of the client bundle even when it is visually inside an interactive container. - Treat the props crossing the boundary as a public API. Serialize only what is needed, never secrets.
- Measure the client bundle. A surprise spike usually means a
'use client'crept too high in the tree.
We build production Next.js apps for clients every month, and the apps that age well are the ones that respect the boundary discipline from day one. If you are migrating an older Next.js app to App Router or starting a new project and want to skip the early mistakes, let us know. We have shipped this and we have opinions.
Tags