Mar 17, 2026 – 4 min read
React Server Components in Practice: What Actually Changes in a Production App
React Server Components stopped being "the future of React" a while ago – with Next.js App Router they're the default present, and most teams I work with are past "what is this?" and deep into "why is my bundle still big and my page still slow?" This is the practical write-up: the mental model that survives production, where to draw the server/client boundary, and the places RSC quietly punishes you.
The mental model that survives
Forget "components that render on the server" – SSR already did that. The accurate model:
- Server Components run only on the server. Their code never ships to the browser. Not "renders first on the server" – the JavaScript literally isn't in the bundle.
- Client Components are the old React you know. They server-render too (HTML for first paint), then hydrate and stay interactive.
- The tree is a sandwich. Server Components can render Client Components and pass them props; the reverse only works via the
childrenslot pattern.
So RSC isn't a rendering optimization – it's a bundle and data-access architecture. The question it answers is: which parts of my UI are just "fetch data, format it, print it" and don't deserve to cost the user any JavaScript?
// Server Component – zero bytes of client JS
async function InvoiceList({ teamId }: { teamId: string }) {
const invoices = await db.invoice.findMany({ where: { teamId } });
return (
<ul>
{invoices.map(inv => (
<li key={inv.id}>
{inv.number} – {formatMoney(inv.total)}
<PayButton invoiceId={inv.id} /> {/* client island */}
</li>
))}
</ul>
);
}
The formatMoney helper, the date library it drags in, the ORM – none of it reaches the browser. Only PayButton hydrates.
Where to draw the boundary
The single decision that determines whether RSC helps you: push "use client" as deep as possible.
The classic mistake is a "use client" at the top of a page because one dropdown needs state – which silently converts the entire subtree into client bundle. The directive marks a boundary, not a component: everything it imports comes along for the ride.
My working rules:
- Pages and layouts stay server. Interactivity lives in leaf islands.
- If a component only needs interactivity for one piece, extract that piece. A server-rendered table with a client
<SortControl>beats a client table. - Watch imports at the boundary like a hawk. One
"use client"file importing a 60kb chart library puts 60kb in the bundle. Bundle analysis is no longer optional hygiene; it's how you verify the architecture is holding. - Serialization is the contract. Props crossing server → client must be serializable – no functions, no class instances, no Dates-that-you-assumed-stayed-Dates. Design the boundary types deliberately, like an API.
Data fetching turns inside out
The RSC promise that actually delivers: data fetching moves to where the data is used, without waterfalls-by-default or client fetch libraries.
- Server Components
awaitdata directly. Secrets stay on the server; there's nouseEffect-fetch-setState dance and no loading-spinner cascade for static content. - Mutations go through Server Actions – a typed function call instead of a hand-rolled
/apiroute, withrevalidatePath/revalidateTagclosing the loop. - The client-state libraries don't die, they shrink: what's left on the client is genuinely client state – optimistic updates, live data, complex interactive flows. TanStack Query for the live parts, RSC for the rest, is a very sane split.
The trap: caching is now a stack, not a setting. Next.js layers the fetch cache, the full route cache, and the client router cache – and "why is this data stale?" is the new "why did this re-render?". My advice is unglamorous: start every dynamic route explicitly dynamic, add caching deliberately per data source, and treat every revalidate value as documentation of a business decision ("pricing may be 60 seconds stale") rather than a performance knob.
When RSC is the wrong tool
RSC earns its complexity when your app is content-heavy, data-driven, and served to users on mediocre devices and networks – dashboards, marketplaces, e-commerce, anything SEO-relevant. It's the wrong default when:
- The app is a dense interactive tool – an editor, a canvas, a trading terminal. If 90% of the tree needs state and events, RSC gives you boundary bureaucracy with little bundle win.
- You don't control a server. Static export (this very site is one) or purely client-deployed apps get nothing from a server-only architecture.
- The team is still fighting hydration basics. RSC stacked on shaky SSR understanding multiplies confusion; the error messages ("cannot pass function to Client Component") arrive at the worst times.
What I'd tell a team adopting it
Treat the server/client boundary as an architectural artifact – reviewed, typed, and measured. Put bundle-size checks in CI the same week you adopt App Router. Keep Server Actions thin (validate, call domain logic, revalidate – nothing else). And resist the urge to make everything server-side: the win isn't "no client JavaScript", it's chosen client JavaScript.
I build and rescue production React and Next.js apps – including the unglamorous part where the bundle analysis meets the roadmap. If that's the conversation you're having internally, here's how I work, or email sinclar96@gmail.com.
Building something in this stack?
Get in touch ↗