React Server Components: A Decision Framework for Server/Client Boundaries in Next.js 15
Stop reflexively adding 'use client' to every component. Learn the decision framework for server/client boundaries in Next.js 15 with production examples.
You're staring at a component that fetches a list of products, renders a filter dropdown, and displays an "add to cart" button. Your cursor is hovering over the top of the file. Do you type "use client" or not?
If your honest answer is "I add it whenever something breaks," this article is for you. That reflex — slap "use client" on anything that throws a hooks error — is how you end up shipping a client bundle that's 400KB heavier than it needs to be, waterfall-fetching data that could've been resolved in parallel on the server, and debugging hydration mismatches at 11pm.
The direct answer: fetch data as close to the root as possible in Server Components, pass the results down as props, and only mark a component "use client" when it needs interactivity, browser APIs, or React state — then push that boundary as far down the tree as it will go. Everything below is the reasoning and the patterns that make that rule actually workable in Next.js 15's App Router.
What React Server Components actually are
React Server Components (RSC) are components that render exclusively on the server — not "run on the server first and then also ship to the client," but render there and generate a serialized description of the resulting UI. The server executes the component and streams that serialized output (the RSC Payload) to the client. The component code itself never ships to the browser, though its rendered output is transmitted client-side to update the DOM.
This is the part that trips people up: it's not about when the code runs, it's about where it's allowed to run, ever. A Server Component has no browser runtime. It cannot access window, localStorage, or document. It cannot use useState, useEffect, or any hook that depends on a live, mounted, client-side React tree — because there is no client-side tree for it. It exists only as a rendering step on the server.
In the App Router, this is the default. Every component you create in the app/ directory is a Server Component unless you explicitly opt out. That's a deliberate inversion from the client-first mental model most of us built over the last decade of React, and it's worth sitting with: you now have to opt into client execution, not opt out of it.
Client Components, by contrast, are the components you already know from pre-RSC React. They render on the server for the initial HTML (so users get fast first paint and don't see a blank screen), and then they also ship their JavaScript to the browser, where React hydrates them into a live, interactive tree. That hydration step is what lets useState update, onClick fire, and useEffect run.
Server Components vs. Client Components: the real contrast
Forget "server has no hooks, client has hooks" — that's a symptom, not the model. The actual contrast is about execution environment and what each environment gives you access to.
| Server Components | Client Components | |
|---|---|---|
| Where it renders | Server only | Server (initial HTML) + browser (hydration) |
| Ships JS to browser | No | Yes |
Can use useState, useEffect, useContext | No | Yes |
Can be async and await directly | Yes | No (must use hooks like use() or client-side fetching libraries) |
| Direct database/filesystem access | Yes | No |
Access to window, localStorage, browser events | No | Yes |
| Can import server-only secrets (API keys, DB credentials) safely | Yes | No — never |
| Re-renders on user interaction | No, it already rendered | Yes |
The database access row and the secrets row are the ones with real security teeth. A Server Component can import your Postgres client and query directly, because that code and those credentials never leave the server. Do that in a Client Component and you've either shipped your database URL to every visitor's browser or you've silently failed at build/runtime. Neon, direct SQL, ORM calls — all of that belongs in the server-rendered part of the tree, full stop.
How Server and Client Components actually work together in Next.js 15
Here's the sequence, because understanding it changes how you architect boundaries.
On the server: Next.js renders your route. Server Components execute, fetching data, querying databases, calling internal APIs — whatever they need. Client Components in that same tree are rendered too, but only for their initial HTML output; React doesn't run their effects or attach interactivity yet. The server produces a stream of HTML plus a serialized RSC payload describing the whole tree, including instructions for where Client Components need to hydrate.
On the client (first load): The browser receives that HTML immediately — the user sees content fast, before any JavaScript has necessarily loaded. Then the JavaScript for the Client Components in the tree downloads and React hydrates them, wiring up event handlers, initializing state, and running effects. Server Components never hydrate; there's nothing to hydrate because they have no client-side code.
On subsequent navigations: Instead of a full page reload, the App Router's client-side router requests the next route, and the server responds with a new RSC payload for the parts of the tree that changed. Client Components that are still present and haven't been unmounted can preserve their state across this transition, while Server Components re-render fresh with new data. This is the mechanism that lets you get server-fetched freshness without losing, say, an open modal's state as the user clicks around.
The practical upshot: your data should enter the tree during the server render, not after hydration. Fetching in a useEffect inside a Client Component means the user sees a loading spinner, then a network request fires from the browser, then data arrives, then a re-render happens. Fetching in an async Server Component means the data is already baked into the HTML the server sent. Same data, radically different perceived performance.
The decision framework: when does a component need "use client"?
Run every component through these questions in order. Stop at the first "yes."
- Does it need interactivity — state, event handlers, or effects? A button that toggles a menu, a form that tracks input, a component using
useStateoruseEffect— these need"use client". There's no way around it; interactivity requires a live tree in the browser. - Does it need browser-only APIs?
window,localStorage,IntersectionObserver, geolocation, anything that only exists in a browser — client boundary required. - Does it depend on React Context that's provided by a Client Component? If it calls
useContextto read from a theme provider or auth context, it needs to be a Client Component (more on this below). - Does it wrap a third-party UI library that assumes client-side React? Most component libraries built before RSC assume they're always hydrated — client boundary required, usually at a thin wrapper layer.
If the answer to all four is no, leave it as a Server Component. That includes components that fetch data, format data, compose layout, or render static markup — even if they're deep in the tree. There's no rule that says data fetching or layout composition needs to happen near the root; you can have Server Components fetching data at any depth, as long as nothing above them in that specific branch has crossed into client territory.
This is the mental model shift: "use client" marks a boundary, not a single component. Once you cross it, every component rendered underneath that one in the tree is also part of the client bundle, unless you explicitly pass Server Components in as children (covered below). So the goal isn't "avoid "use client" entirely" — it's "put the boundary as low in the tree as the interactivity requirement actually demands."
The data-fetching boundary: fetch on the server, hand data to interactive leaves
The pattern that makes this framework actually work in practice: treat data fetching as a server-side concern that produces plain serializable data, and treat interactivity as a client-side concern that consumes that data as props.
The database query happens entirely on the server. The products array is serialized and passed as a prop into the Client Component, which is only responsible for the interactive filtering logic. Notice what's not in the client bundle: your database client, your query logic, any server-side utilities. Only ProductFilters and its state logic ship as JavaScript.
The constraint to remember: props passed from Server to Client Components must be serializable. Plain objects, arrays, strings, numbers — fine. Functions, class instances, Dates without conversion, or React elements created inside a Server Component with server-only behavior baked in — not fine, or at least not safely. If you need to pass a callback down, that callback has to be defined in a Client Component (or be a Server Action, which is a different, explicitly-marked mechanism for server mutations triggered from the client).
Interleaving Server and Client Components
Here's the pattern most people miss: Client Components can receive Server Components as children, as long as those Server Components are rendered by a parent Server Component and passed through the children prop rather than imported directly inside the Client Component's file.
ClientShell never imports DashboardContent directly — it just receives it as children, already rendered by the server. This means the toggle-collapse interactivity lives in a small client bundle, while the actual dashboard content — which might do more data fetching — stays server-rendered. This is the single most useful pattern for keeping "use client" boundaries thin: wrap, don't absorb.
Context providers and third-party components
Two categories force a client boundary almost by definition, but you can still contain the damage.
Context providers — theme, auth session, feature flags — need "use client" because useContext requires a live client tree. The fix is to isolate the provider itself as a thin Client Component near the root, then let everything it wraps stay as Server Components where possible:
Wrap your root layout's children in <Providers> once. The provider component is client-side, but the children passed into it can still be Server Components, following the same children-passthrough pattern as above.
Third-party components that weren't built with RSC in mind — most charting libraries, some older UI kits — usually need a wrapper:
You isolate the "use client" cost to that one wrapper file rather than infecting everything that imports it.
Pushing "use client" down to shrink the bundle
Every time you write "use client" at the top of a file, ask: can I split this component so only the truly interactive part carries the directive? A page that has one button needing onClick doesn't need the whole page marked client — extract the button (and only the button) into its own file, mark that file, and leave the rest of the page as Server Components passing it data as props. That single habit — decomposing components until the client boundary is as small as possible — is the difference between a lean bundle and a bloated one, and it's the crux of the entire framework above.
Putting this into practice
If this framework clicked for you and you're ready to apply it to a real codebase — particularly if you're building a full-stack Next.js application with actual data fetching, authentication, and production concerns baked in — Damian Hodgkiss has published working tutorials and production-ready patterns for App Router architecture, including containerization, webhooks, caching strategies, and Clerk integration. His site is a useful reference point when you're translating this decision framework into routes, Server Actions, and actual deployment.
FAQ
Do Server Components support useEffect or useState at all?
No. Server Components render once, on the server, and produce output — there's no live tree for state to update or an effect to run against. Any component using these hooks must have "use client".
Can a Server Component import a Client Component? Yes — that's the normal case. A Server Component fetches data and renders a Client Component, passing serializable data as props.
Can a Client Component import a Server Component directly?
Not by direct import inside the same file's module graph in a way that keeps it server-rendered — pass it through children or another prop instead, rendered by a parent Server Component.
Does marking one component "use client" make its children client-rendered too?
Yes, by default — unless those children are passed in via children or props from a Server Component parent, in which case they retain their original execution environment.
Where should database calls live? In Server Components (or Server Actions for mutations), never in Client Components, since client-side code ships to the browser and can't safely hold credentials or direct connection logic.
Damian Hodgkiss
Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.