React Server Components are simple in principle and confusing in practice, and the confusion is nearly always the same one: where is the boundary, and which side am I on?
Get that clear and everything else follows. So this starts there rather than with the architecture.
The one-sentence version
A Server Component runs on the server, produces HTML, and never ships its JavaScript to the browser.
That is the whole benefit. Not "server rendering" — React has done that for years. The new part is that the component's code stays on the server. A component that formats dates using a 60 KB library adds 60 KB to your bundle as a Client Component and nothing at all as a Server Component.
Everything else is consequences of that one fact.
Why you cannot use useState
Because the component already ran, finished, and sent HTML. There is no component sitting in the browser to hold state.
The same reasoning explains the whole list of things that do not work in a Server Component:
useState,useReducer— nothing there to hold stateuseEffect— no browser lifecycle to hook intoonClickand every other event handler — nothing listeningwindow,document,localStorage— no browser- Context providers — nothing to provide to
And correspondingly, what you can do in a Server Component and cannot do in a Client Component:
awaitdirectly in the component body- Query your database
- Read files, environment variables and secrets
- Use large libraries with zero bundle cost
That symmetry is the entire mental model. Server Components trade interactivity for data access and zero bundle weight.
In the App Router, Server is the default
This is the part that trips people coming from older React: every component is a Server Component unless you say otherwise. 'use client' opts in to the browser.
// A Server Component. No directive needed.
export default async function ArticleList() {
const articles = await db.article.findMany() // direct database access
return <ul>{articles.map(a => <li key={a.id}>{a.title}</li>)}</ul>
}'use client'
import { useState } from 'react'
export default function Counter() {
const [n, setN] = useState(0)
return <button onClick={() => setN(n + 1)}>{n}</button>
}Note what disappeared in the first one: no getServerSideProps, no API route, no fetch from the client, no loading state, no useEffect. The data-fetching ceremony that dominated React applications for a decade collapses into await.
The rule that resolves almost everything
'use client' marks a boundary, not a component.Everything imported by a Client Component becomes client code too, all the way down. Marking one component at the top of your tree pulls the entire tree into the browser bundle and quietly discards the whole benefit.
This is the single most expensive mistake in App Router projects, and it is invisible — nothing breaks, the app just ships far more JavaScript than it needs to.
Therefore: push 'use client' as far down the tree as it will go. Not the page — the button. Not the layout — the dropdown.
The pattern that makes this practical: a Client Component may still render Server Components, as long as they arrive as children rather than being imported.
// Server Component — stays on the server
<InteractiveTabs>
<ExpensiveServerRenderedContent />
</InteractiveTabs>InteractiveTabs is a Client Component handling the tab state. The content inside it was rendered on the server and passed through as an already-rendered child. The tab logic ships; the content does not.
Once that clicks, most "I have to make this whole page a client component" problems dissolve.
What crosses the boundary
Props passed from a Server Component to a Client Component must be serialisable, because they are literally serialised and sent over the network.
Passes | Does not pass |
|---|---|
Strings, numbers, booleans, null | Functions |
Plain objects and arrays | Class instances |
Dates, Maps, Sets | Symbols |
JSX (as children) | Anything holding a closure |
The functions restriction is the one that bites. You cannot pass a callback down to a Client Component — with one important exception: Server Actions, which are functions marked 'use server' and can be passed, because what actually crosses is a reference the client calls back over the network rather than the function itself.
The mistakes that cost the most
1. 'use client' at the top of the tree. Covered above. This is the big one.
2. Assuming Server Components are always faster. They move work to the server. A Server Component doing something slow makes your server slow instead of the browser, and now every visitor waits for it. Server rendering is not free, it is relocated.
3. Fetching in a waterfall. Sequential awaits in nested Server Components serialise your requests. Use Promise.all for anything independent — the mistake is easier to make here than in the old client-side world, because it reads so naturally.
4. Forgetting the loading state. A Server Component that awaits slow data blocks the whole page until it resolves. Wrap it in <Suspense> with a fallback and the rest of the page renders immediately.
5. Leaking secrets. Environment variables are available in Server Components. If you pass one as a prop to a Client Component, it is now in the browser. React will warn you in many cases; it will not catch all of them.
When to reach for each
Server Component — the default, and most of your application:
- Anything fetching data
- Static content, layouts, headings
- Anything using a large formatting or parsing library
- Anything touching secrets or the database
Client Component — only when you need one:
- State, event handlers, effects
- Browser APIs
- Third-party components that use hooks internally
- Anything animating in response to the user
A useful heuristic: if it does not respond to the user, it does not need to be a Client Component.
What it is actually worth
Being honest about the size of the win.
A genuine improvement when: your app is content-heavy, your bundle has grown large, you are fetching a lot of data, or you have accumulated a stack of useEffect calls whose only job is loading things.
Marginal when: your app is a highly interactive dashboard where nearly everything is a Client Component anyway. You get the boundary complexity and not much of the payoff.
Costly when: your team is new to it. The mental model is genuinely different, and the failure mode — a working app that ships too much JavaScript — is invisible without measuring. Check the bundle size, not the behaviour.
For most content-driven sites the reduction in client JavaScript is substantial, and it shows up in Core Web Vitals as better INP, because there is simply less script to execute. this is the kind of work we do if you would rather hand it over.
How to check you have got it right
# Which components are actually client components?
grep -rl "use client" ./app ./components | wc -l
# And how big is the bundle you are shipping?
next build # read the First Load JS columnTwo things to look for. If almost everything is a Client Component, the boundary is in the wrong place. And if First Load JS is growing release over release, something near the top of the tree has picked up a 'use client' — that number is the honest scoreboard for whether the architecture is doing anything for you.
Server Actions, briefly
The other half of the model, and the part that makes it usable.
A Server Action is a function marked 'use server' that a Client Component can call as though it were local — but it executes on the server:
// actions.ts
'use server'
export async function subscribe(formData: FormData) {
const email = String(formData.get('email'))
await db.subscriber.create({ data: { email } })
}<form action={subscribe}>
<input name="email" type="email" required />
<button>Subscribe</button>
</form>No API route, no fetch, no JSON, no client-side loading state. The form works before JavaScript loads, because it is a real form posting to a real endpoint — progressive enhancement you get by default rather than by effort.
Two things to be careful about:
A Server Action is a public HTTP endpoint. Next.js generates a URL for it. Anyone can call it with any arguments. Validate every input and check authorisation inside the action itself — never rely on the fact that your UI only offers valid options, because the UI is not what an attacker uses.
Revalidate after writing. The page will not refresh itself. Call revalidatePath or revalidateTag at the end of the action, or your user submits a form and watches nothing change.
Between Server Components for reading and Server Actions for writing, the API layer that used to sit between your database and your UI largely disappears for a typical application. That is the real productivity story of this architecture, and it is a bigger deal than the bundle-size argument.
Where the model came from
A short bit of history, because it makes the design decisions make sense.
React began as a client-side library. Everything ran in the browser, and the server's job was to send an empty page and a bundle. That was a genuine improvement over what came before, and it had one structural problem: every capability you added had to be shipped to every visitor.
The industry's answers arrived in order. Server-side rendering fixed the blank first paint but still shipped everything, because the page had to hydrate. Code splitting reduced how much arrived at once, without reducing the total. Static generation solved it for content that never changes, and not for anything else.
Server Components are the first answer that addresses the cause rather than the symptom: some components do not need to exist in the browser, so they should not go there at all.
That framing explains the parts that feel arbitrary. You cannot use useState in a Server Component not because of a limitation but because the component has already finished. Props must serialise because they cross a network. 'use client' marks a boundary rather than a component because the boundary is the actual concept — everything past it must ship.
Once the model is "the browser gets only what it must", the rules stop being rules to memorise and become consequences to reason from. Which is the point at which this architecture starts being easier than what it replaced, rather than harder.
Frequently asked questions
What is the difference between Server Components and server-side rendering? SSR renders your components to HTML on the server and then sends the JavaScript so React can hydrate them. Server Components never send that JavaScript at all — the component's code stays on the server permanently. SSR is about the first paint; RSC is about the bundle.
When do I need "use client"? When the component uses state, effects, event handlers or browser APIs. Nothing else. Put it on the smallest component that needs it, never on a page or layout.
Can a Client Component render a Server Component? Not by importing one — but yes by receiving one as children. That composition pattern is how you keep an interactive wrapper around server-rendered content without pulling the content into the bundle.
Why can I not pass a function as a prop to a Client Component? Props are serialised and sent over the network, and functions do not serialise. The exception is Server Actions ('use server'), where what crosses is a callable reference rather than the function itself.
Are Server Components faster? They reduce client JavaScript, which usually improves load and interaction metrics. They do not make slow work fast — they relocate it to your server. A slow database query is slow wherever it runs.
Do Server Components work outside Next.js? They are a React feature, but they need a framework or bundler that implements the protocol. Next.js App Router is the most mature implementation; others exist and are less complete.

.webp&w=128&q=75)
.webp&w=256&q=75)