Server components in one sitting
Where the server/client boundary actually falls, what crosses it, and the four mistakes that turn a server component tree back into a client-rendered app.
In one sentence
Server components run once, on the server, and send their rendered output —
never their code — to the browser; use client marks the boundary where that
stops being true.
Why it matters
The default in the App Router is inverted from what most React developers expect. Every component is a server component unless something marks it otherwise. That is not a rendering optimisation bolted on top of the old model; it is a different model, and the bugs come from carrying old assumptions into it.
What each kind can do:
| Capability | Server component | Client component |
|---|---|---|
async / await in the body | Yes | No |
| Read secrets, hit the database | Yes | Never |
useState, useEffect | No | Yes |
onClick and other handlers | No | Yes |
| Ships JavaScript to the browser | No | Yes |
| Re-renders after mount | No | Yes |
The size win is the part people notice; the data-fetching win is the part that
changes how you write code. A server component can await directly, which
deletes the loading-state machinery that a useEffect fetch requires:
// app/orders/page.tsx — a server component. No 'use client'.
export default async function OrdersPage() {
const orders = await db.order.findMany({ take: 20 }) // runs on the server only
return <OrderList orders={orders} />
}
There is no isLoading, no useEffect, no request waterfall from the browser,
and db never reaches the client bundle.
Where the boundary actually falls
use client does not mean "this component renders in the browser and its parent
does not". It marks an entry point into the client bundle: that file, and
everything it imports, becomes client code.
app/page.tsx (server)
└── <Dashboard> (server)
├── <Chart> 'use client' → client, and so is everything Chart imports
└── <RecentOrders> (server)
Two consequences that surprise people:
-
A server component cannot be imported by a client component. The import would pull it into the client bundle, where
await db.…is meaningless. Pass it aschildrenor a prop instead — that composes fine, because it is rendered on the server and passed through as already-rendered output.// Works: the client component receives rendered output, not the component. <ClientTabs> <ServerOnlyPanel /> </ClientTabs> -
Props that cross the boundary must be serializable. Strings, numbers, plain objects, arrays, Dates, Maps, Promises — yes. Functions, class instances, and Symbols — no. The error message names the prop, which is the fastest thing about debugging it.
Server components still render on the server for the client too
The naming is the single biggest source of confusion, so it is worth stating
plainly: a client component is also rendered on the server during the initial
request. use client does not mean "client-only". It means "this also runs in
the browser, so it must also be shipped there".
That is why window is not defined errors happen inside components marked
use client. The fix is to guard the browser-only access, not to remove the
directive:
'use client'
export function Width() {
const [width, setWidth] = useState<number | null>(null)
useEffect(() => setWidth(window.innerWidth), []) // effects never run on the server
return <span>{width ?? '—'}</span>
}
The four mistakes that undo the whole thing
1. use client at the top of the layout. Everything below it becomes a client
component, and you have rebuilt a client-rendered SPA with extra build steps. Push
the directive down to the leaf that actually needs interactivity — usually a
button, a form, or a chart.
2. A use client file that re-exports server code. Barrel files are the usual
culprit: components/index.ts marked use client drags every component in the
directory into the bundle. Import from the specific file.
3. Sequential awaits that should be parallel. Server components make waterfalls easy to write and invisible to spot.
// Two round trips, one after the other
const user = await getUser(id)
const orders = await getOrders(id)
// One round trip's worth of wall clock
const [user, orders] = await Promise.all([getUser(id), getOrders(id)])
4. Leaking server-only modules. Anything that reads process.env secrets or
opens a database connection should import server-only at the top, so an
accidental client import fails at build time rather than shipping your credentials.
import 'server-only'
export const db = createClient(process.env.DATABASE_URL!)
Streaming is the other half
Because the server renders progressively, a slow section does not have to hold
back the whole page. Wrap it in <Suspense> and the shell is sent immediately,
with the slow part streamed in when it resolves.
export default function Page() {
return (
<>
<Header /> {/* sent immediately */}
<Suspense fallback={<OrdersSkeleton />}>
<Orders /> {/* streamed when its await resolves */}
</Suspense>
</>
)
}
The rule of thumb: put a Suspense boundary anywhere the data is slower than the rest of the page and the user can do something useful without it.
Common pitfalls
- Reaching for
useEffectto fetch data in a component that could simply have beenasync. - Marking a component
use clientto fix a hydration mismatch. The mismatch is usually non-deterministic output —Date.now(),Math.random(), or a locale format that differs between server and browser. - Assuming server components re-run on interaction. They do not; they re-run on navigation or when you invalidate their cache.
- Passing a large payload across the boundary as props because it was convenient. It is serialized into the HTML response, and you pay for every byte twice.
Further reading
- Server and Client Components — the normative description of the boundary.
server-only— the build-time guard, worth adding on day one.- You Might Not Need an Effect — still the best filter for what belongs on the client at all.