Short answer
React Server Components move the component boundary, not just the rendering. A server component never ships to the browser and never re-renders — it runs once, produces a serialised description of UI, and its dependencies stay on the server. The architectural work is deciding where the boundary sits, because everything below a 'use client' file is client code again.
The common description — “components that render on the server” — is the part that matters least. Server-side rendering already did that. What is new is that a server component’s code never reaches the client at all, which changes what belongs in a component and what a bundle actually contains.
The mental model
Every component is a server component by default. Adding 'use client' at the top of a file marks it, and everything it imports, as client code.
| Server component | Client component | |
|---|---|---|
| Ships JavaScript to the browser | No | Yes |
Can be async and await data |
Yes | No |
Hooks (useState, useEffect) |
No | Yes |
| Event handlers | No | Yes |
| Direct database or filesystem access | Yes | No |
| Re-renders on interaction | No | Yes |
| Access to secrets | Yes | Never |
The consequence people miss: 'use client' is a boundary, not a label. It marks an entry point into client-land. A client component importing a 200 KB charting library puts that library in the bundle regardless of how many server components sit above it.
What can cross the boundary
Props passed from a server component to a client component are serialised, so they must be serialisable. Strings, numbers, booleans, plain objects, arrays, Date, Map, Set, promises and JSX all cross. Functions, class instances and closures do not.
The one exception is worth knowing: a function marked 'use server' — a server action — can be passed to a client component, because what actually crosses is a reference the client can invoke, not the function body.
// app/page.tsx - server component, no 'use client'
import { db } from '@/lib/db';
import { Filter } from './filter'; // client component
export default async function Page({ searchParams }) {
// Runs on the server. `db` never enters the client bundle.
const rows = await db.article.findMany({ where: { tag: searchParams.tag } });
return (
<>
<Filter initial={searchParams.tag} /> {/* interactive island */}
<ul>
{rows.map(r => <li key={r.id}>{r.title}</li>)} {/* stays server-rendered */}
</ul>
</>
);
}
The pattern that keeps bundles small
Push the boundary down, as close to the interactive element as possible. The instinct is to mark a page as a client component because one button needs onClick; that converts the whole subtree.
The alternative is to keep the page as a server component and pass server-rendered content into a client component as children:
// Accordion is a client component, but its children are rendered on the
// server and passed in as already-serialised JSX. The article body's
// markdown renderer never reaches the browser.
<Accordion title="Full specification">
<ArticleBody source={doc} /> {/* server component */}
</Accordion>
This works because children is a prop, and JSX is serialisable. The client component controls open and closed state; it never needs the code that produced the content.
Where data fetching moves
The useEffect-then-fetch pattern exists because components could not await. Server components can, which removes the waterfall: request arrives, data is fetched on the server, HTML streams out. No client round trip, no loading spinner for initial content, no API route that exists only to feed your own front end.
Two rules that follow:
- Fetch where the data is used, not at the top and drilled down. Requests are deduplicated within a render pass, so two components asking for the same thing produce one call.
- Parallelise deliberately. Sequential
awaits create a server-side waterfall.Promise.allfor independent data, andSuspenseboundaries so slow sections stream in without blocking the rest.
What goes wrong in practice
'use client'creeping up the tree. One component needs a hook, someone marks the layout instead, and the entire application is a client bundle again. Audit which files carry the directive.- Context providers at the root. A provider needs client rendering, so wrapping the root layout in one converts everything. Wrap the smallest subtree that needs the context.
- Passing functions as props. Fails at serialisation. Either the receiving component owns the behaviour, or the function becomes a server action.
- Secrets leaking. A server component can read
process.env.API_KEYsafely; the moment that value is passed as a prop to a client component it is in the HTML payload. This is the most consequential mistake available here. - Assuming no re-render means no cost. Server components run on every uncached request. An unbounded query in one is a server-side performance problem, not a client one — the discipline in Full-Stack Web Performance applies unchanged.
When not to use them
An application that is mostly interactive — a dashboard, an editor, a canvas tool — has little server-renderable surface, and the boundary bookkeeping costs more than it returns. Server components pay off when there is substantial content to render and a limited number of interactive islands, which describes most content and commerce sites and few internal tools.
The routing, layout and streaming machinery around them is covered in Next.js App Router, and the caching behaviour that decides how often a server component actually re-runs is in Next.js Caching Explained.
Questions people actually ask
- Are server components the same as server-side rendering?
- No. SSR renders client components to HTML on the server, then ships their JavaScript so they can hydrate. A server component’s JavaScript is never sent at all and it never hydrates. You can use both together, and in practice most applications do.
- Why can’t I use useState in a server component?
- Because it never re-renders. State exists to trigger a re-render on change, and a server component runs once per request and produces static output. If a component needs state, it needs
'use client'– or the state belongs in a small client component nested inside it. - Does ‘use client’ mean the component is not server-rendered?
- No, and this is the most common misreading. Client components are still rendered to HTML on the server for the initial response. The directive controls whether the component’s JavaScript is sent to the browser and hydrated, not whether HTML is produced.
- How do I share state between server and client components?
- You do not – the server has no persistent state to share. Pass serialisable data down as props, put shared client state in a provider wrapping only the client subtree that needs it, and use URL search params for state that should survive a reload or be linkable.
- How do I check what is actually in my client bundle?
- Run a bundle analyser and look for libraries you expected to stay on the server. A date or markdown library appearing in the client chunk means a
'use client'boundary sits above something that imports it – trace the import chain upward from the surprise and move the boundary down.