React Server Components vs Client Components: When to Use Each in Next.js 15

Every few months a client sends me a Next.js codebase that "feels slow" and asks for an audit. Nine times out of ten, the problem is not the database or the CDN. It is a "use client" directive sitting at the top of a layout file, quietly dragging half the app into the browser bundle. That one line is the whole reason I keep writing about React Server Components vs Client Components. It is the single decision in Next.js 15 that has the biggest effect on bundle size, time to interactive, and how much of your backend you accidentally expose.
So let me give you the version I wish someone had handed me a couple of years ago: the mental model, the decision rules I actually use on client work, and the mistakes I keep seeing in code reviews.
The one-sentence mental model
Server Components run on the server, never ship JavaScript to the browser, and can talk to your database directly. Client Components run in the browser (and also on the server for the initial HTML), ship their JavaScript, and are the only place you can use state, effects, and event handlers.
That is it. Everything else is consequences.
In Next.js 15 with the App Router, every component is a Server Component by default. You opt into the client with "use client" at the top of the file. Not a function, not a hook, not a block. The whole file, and everything it imports that is not itself a Client Component boundary, becomes client code.
That last part surprises people. If page.tsx has "use client" and imports a charting library, that library is now in the browser bundle. It does not matter that you only render the chart conditionally.
What the two actually do differently
Here is the comparison I keep on a sticky note.
| Capability | Server Component | Client Component |
|---|---|---|
| Runs on | Server only | Server (SSR) + browser |
| Ships JS to browser | No | Yes |
useState, useEffect, useRef |
No | Yes |
onClick, onChange, event handlers |
No | Yes |
async component body with await |
Yes | No |
| Direct DB / filesystem / secrets access | Yes | No |
useContext for client context |
No | Yes |
Access to window, document, localStorage |
No | Yes |
| Can import a Client Component | Yes | Yes |
| Can be imported by a Client Component | Only via children prop |
Yes |
| Streaming with Suspense | Yes | Limited |
The row that trips up the most developers is the second to last. You cannot just import ServerComponent from './ServerComponent' inside a Client Component file and render it. Next.js will treat it as a client component and the build will complain, or worse, silently bundle it. The correct pattern is composition: pass the Server Component in as children.
The decision framework I actually use
When I am writing a new route, I ask four questions in order. The first "yes" wins.
- Does it need interactivity (click, type, drag, hover state)? Client.
- Does it need browser APIs (
window,IntersectionObserver,localStorage, geolocation)? Client. - Does it need React state, effects, reducers, or context that changes at runtime? Client.
- Does it need to fetch data, read secrets, or touch the database? Server.
If none of those apply, it stays a Server Component. And in my experience, that is roughly 70 to 80 percent of the components in a typical marketing site or dashboard shell. Static headers, footers, blog cards, product grids, pricing tables, documentation sidebars: all server. The interactive islands (search box, cart drawer, theme toggle, form with validation) are client.
A concrete example
Say I am building a product listing page. The naive version puts "use client" on the page because the page has a filter dropdown.
// app/products/page.tsx -- WRONG
"use client";
import { useState } from "react";
export default function ProductsPage() {
const [category, setCategory] = useState("all");
const [products, setProducts] = useState([]);
// fetch on the client, show a spinner, ship the whole page as JS
// ...
}
This works. It is also the version that ships the entire page, the product card component, and the price formatting library to every visitor, then makes them wait for a client-side fetch before they see anything.
The version I write instead:
// app/products/page.tsx -- Server Component
import { db } from "@/lib/db";
import { ProductGrid } from "./product-grid";
import { CategoryFilter } from "./category-filter";
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const { category = "all" } = await searchParams;
const products = await db.product.findMany({
where: category === "all" ? {} : { category },
select: { id: true, name: true, price: true, slug: true },
});
return (
<main>
<h1>Products</h1>
<CategoryFilter current={category} />
<ProductGrid products={products} />
</main>
);
}
ProductsPage and ProductGrid are server components. They hit the database with a Prisma call, render HTML, and ship zero JavaScript. CategoryFilter is the only client component, and it is tiny:
// app/products/category-filter.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
const categories = ["all", "shoes", "bags", "accessories"];
export function CategoryFilter({ current }: { current: string }) {
const router = useRouter();
const params = useSearchParams();
function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
const next = new URLSearchParams(params);
next.set("category", e.target.value);
router.push(`/products?${next.toString()}`);
}
return (
<select value={current} onChange={onChange}>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
);
}
The filter changes the URL, the server re-renders the page with the new searchParams, and the browser gets fresh HTML. No client-side data fetching, no loading spinner, no stale state to reconcile. The JavaScript payload for this route is roughly the size of the select element plus the router.
I have shipped this exact pattern on client storefronts and it is consistently the fastest version. It also has a nice side effect: the URL is shareable and the back button works, which is not always true when you keep filter state in useState. If you want to go deeper on the mutation side of the same architecture, my walkthrough of Next.js 15 Server Actions for form handling and data mutations picks up exactly where this leaves off.
Server Components use cases in real projects
Where server components earn their keep, from projects I have actually shipped:
- Data-heavy dashboards. Query Postgres or call an internal API with
awaitright in the component. NouseEffect, no loading state, no client-side cache to invalidate. - Content pages. Blog posts, docs, marketing pages, pricing tables. Almost entirely server, with a small client island for the mobile nav.
- Authenticated shells. Read the session cookie on the server, redirect if not logged in, render the layout. The user never sees a flash of unauthenticated content.
- Anything touching secrets. API keys, service tokens, internal endpoints. If it is server-only, it cannot leak into a bundle.
- Heavy dependencies. Markdown parsers, syntax highlighters, date libraries, chart renderers. Run them on the server and ship the output, not the library.
That last one is underrated. I have cut a couple hundred kilobytes off first-load JavaScript just by moving a markdown renderer and a syntax highlighter from a client component to a server component. The rendered HTML is identical. The bundle is not.
Client components performance: when the trade is worth it
Client components are not the enemy. They are the only place interactivity can live. The trick is keeping them small and pushing them to the leaves of your component tree.
Use client components for:
- Forms with validation, autosave, or optimistic updates
- Search with debounced input
- Modals, drawers, popovers, tooltips
- Theme toggles and anything reading
localStorage - Charts and maps that need
window - Drag and drop, rich text editors, file uploads with progress
- Anything using
useContextfor runtime state
The performance cost comes from three places, and it is worth naming them because "client components performance" gets discussed as one thing when it is really three:
- Bundle size. Every client component's code, plus its imports, goes to the browser. A single
"use client"at the top of a layout can pull in an entire design system. - Hydration cost. The browser has to run React over the server-rendered HTML to attach event handlers. More client components, more hydration work, longer time to interactive.
- Re-render cost. Client components re-render on state changes. Server components render once per request.
The fix for all three is the same: keep the client boundary as low and as narrow as possible. A <SearchBox /> that is a client component inside a server-rendered header is cheap. A header that is a client component because of the search box is expensive.
The composition pattern that saves you
You will hit this wall: you have a Client Component (say, a tabs component) and you want to render Server Component content inside each tab. You cannot import the server component into the client file. You pass it as children.
// app/dashboard/tabs.tsx
"use client";
import { useState } from "react";
export function Tabs({
overview,
activity,
}: {
overview: React.ReactNode;
activity: React.ReactNode;
}) {
const [tab, setTab] = useState<"overview" | "activity">("overview");
return (
<div>
<div role="tablist">
<button onClick={() => setTab("overview")}>Overview</button>
<button onClick={() => setTab("activity")}>Activity</button>
</div>
<div>{tab === "overview" ? overview : activity}</div>
</div>
);
}
// app/dashboard/page.tsx
import { Tabs } from "./tabs";
import { OverviewPanel } from "./overview-panel"; // server
import { ActivityPanel } from "./activity-panel"; // server
export default function DashboardPage() {
return (
<Tabs
overview={<OverviewPanel />}
activity={<ActivityPanel />}
/>
);
}
Tabs is a client component and stays small. OverviewPanel and ActivityPanel are server components that fetch their own data and ship no JavaScript. Both panels render on the server, and the client component just decides which one to display. This is the pattern I reach for constantly, and it is the one most developers miss when they first hit the "can't import a server component into a client component" error.
Common pitfalls I see in code reviews
A short list, earned the hard way.
"use client" on the root layout. This is the nuclear option. It makes every route a client route. I have seen it added to fix a hydration warning. Do not do this. Find the actual component that needs interactivity and move the directive there.
Passing functions as props from Server to Client components. You cannot. Functions are not serializable across the boundary. If you need to pass behavior, pass data and let the client component decide what to do with it, or use a Server Action.
Importing a server-only module into a client component. Database clients, fs, crypto with secrets. Next.js will usually catch this at build time, but not always. Use the server-only package to make it explicit and fail loudly.
Putting async on a client component. Client components cannot be async. If you need data on the client, use useEffect, a data library, or better, fetch it on the server and pass it down.
Fetching the same data twice. Once in a server component, then again in a client component that re-fetches on mount. I see this in dashboards constantly. Pick one. Usually the server.
Using useSearchParams in a server component. It does not exist there. In Next.js 15, searchParams is a prop, and it is a Promise you await. This changed from earlier versions and it catches people upgrading.
Migration checklist from a Pages Router or client-heavy app
If you are moving an existing app toward RSC, this is the order I follow.
- Move the
"use client"boundary from layouts down to the smallest interactive leaf. - Convert data fetching in page components from
useEffect+fetchtoawaitin anasyncserver component. - Replace client-side auth checks with server-side session reads and
redirect(). - Pass Server Components into Client Components as
childreninstead of importing them. - Move heavy libraries (markdown, charts, syntax highlighting) behind the server boundary.
- Add
server-onlyto modules that must never run in the browser. - Measure. Compare first-load JS before and after with the Next.js build output.
That last step matters. I have taken a client dashboard from a few hundred kilobytes of first-load JS down to well under a hundred with steps 1, 2, and 5 alone, and I would not have known it without reading the build output. Next.js prints per-route bundle sizes on every build. Read them.
A quick note on Next.js 15 specifics
If you are coming from Next.js 13 or 14, two things changed that affect this decision. params and searchParams are now Promises in server components, so you await them. And caching defaults shifted, so fetches are not cached by default the way they used to be. Neither changes the server vs client decision, but both will bite you mid-migration if you copy older examples. I keep a short note in every project README about which Next.js version the patterns assume, because plenty of tutorials online still show the 13-era signatures.
If you are weighing this architecture against other tooling choices, two related reads are worth your time: ChatGPT vs Claude for building websites for the AI-assistant side of the workflow, and whether AI can replace WordPress developers if you are deciding where to invest your own time. And if you are hiring for this exact stack, my guide on how to hire a Next.js developer covers the portfolio signals and red flags that actually predict whether someone understands the server/client boundary.
FAQ
Q: Can a Client Component render a Server Component?
A: Not by importing it. You can pass a Server Component into a Client Component as a prop (children or a named prop) and render it there. The Server Component still renders on the server, and the client component just places the resulting output in the tree. This composition pattern is how you build interactive wrappers around server-rendered content.
Q: Do Server Components ever run in the browser?
A: No. They run on the server during the request (or at build time for static routes) and produce a serialized payload that React uses to render HTML. Their code never reaches the browser bundle. That is why they can safely read secrets, query databases, and import large libraries.
Q: Is it bad to make everything a Client Component?
A: It works, and for a small interactive app it might be fine. But you lose the main benefits of the App Router: smaller bundles, direct data access, and streaming. The cost shows up as slower time to interactive and larger JavaScript payloads, especially on mobile connections. My rule is to keep the client boundary as small as the interactivity requires and no larger.
Q: How do I know if a component should be a Server or Client Component?
A: Ask whether it needs state, effects, event handlers, or browser APIs. If yes, it is a client component. If no, keep it on the server. When in doubt, start with a Server Component and only add "use client" when the build or the browser tells you that you need it.
Wrapping up
The React Server Components vs Client Components decision is not really about which is better. It is about placement. Server components are the default and the bulk of your tree. Client components are the small, deliberate islands where interactivity lives. Get the boundary right and Next.js 15 gives you fast pages with less JavaScript than you have ever shipped. Get it wrong and you are back to a single-page app with extra steps.
If you are working through this on a real codebase and want a second pair of eyes, I do architecture reviews and Next.js performance audits for teams in Bangladesh, Australia, the US, Canada, and New Zealand. You can reach me through the contact page on this site. Bring your build output. That is usually where the story is.
Frequently asked questions
Can a Client Component render a Server Component?
Not by importing it directly. You pass the Server Component into the Client Component as a prop, usually children, and render it there. It still executes on the server, and the client component only places the rendered output into the tree, which keeps the client bundle small.
Do Server Components ever run in the browser?
No. They execute on the server during the request, or at build time for static routes, and never ship their code to the browser. That is why they can read secrets, query databases, and import heavy libraries without affecting the client bundle.
Is it bad to make everything a Client Component?
It works, but you lose the main advantages of the App Router: smaller bundles, direct data access, and streaming. The cost shows up as slower time to interactive and larger JavaScript payloads, particularly on mobile. Keep the client boundary as small as the interactivity requires.
How do I decide if a component should be a Server or Client Component?
Ask whether it needs state, effects, event handlers, or browser APIs. If yes, it is a client component. If no, keep it on the server. Start with a Server Component and add "use client" only when something actually requires it.