A pragmatic take on server components
/6 min read
Where the model pays off, where it bites, and where I keep clients.
What the server model actually buys you
Most of the UI I ship is data-shaped. A product table, an order timeline, a settings page that reads three rows and renders them. None of it needs state, effects, or event handlers beyond a link. Server components let that code be what it actually is: an async function that fetches, transforms, and returns markup. There is no API route in the middle, no loading spinner choreography, no serialising a response just so the client can deserialise it again. The component runs next to the data, and the browser receives finished HTML plus a small payload to hydrate the interactive bits.
The second win is what never reaches the browser. A markdown renderer, a date library, an ORM client, a syntax highlighter: on this portfolio and on client work, those dependencies execute on the server and ship zero bytes. That changes how I evaluate packages. A heavy library is fine if it only ever runs at render time on a machine I control. The bundle conversation stops being about shaving kilobytes off shared chunks and becomes a simpler question: does this code need to run in the browser at all? Most of the time the honest answer is no.
There is also an architectural payoff that took me longer to appreciate. When components can fetch, the data requirements live next to the markup that uses them, and React deduplicates the calls. I stopped building a parallel tree of loaders and endpoints that mirrored the component tree and then drifted out of sync with it. Streaming with Suspense falls out of the same model: the shell renders immediately and the slow query arrives when it arrives. None of this is new. It is the old server-rendered page, recovered, with the composition model that PHP never had.
Where it bites
The first place it bites is the boundary. Props crossing from server to client must be serialisable, so the callback you casually passed down fails at render time, and the fix is rarely obvious to someone new to the model. 'use client' is not a label on a component, it is a directive on a module, and everything that module imports comes along for the ride. I have watched one convenience import drag half a design system into the client bundle. The mental model is learnable, but the error messages assume you already have it.
The second is the ecosystem seam. Plenty of libraries still assume a browser, or hand you a context provider that forces a client boundary higher than you want it. Debugging is split across two runtimes: a server component failure shows up in the terminal while the hydration mismatch shows up in the browser console, and stitching that story together takes practice. And because server components re-execute per request unless cached, an innocent fetch in a deeply nested component can quietly become a per-request cost that nothing in the type system warns you about.
The third bite is self-inflicted: treating server components as a purity contest. I have seen codebases contort themselves to keep a component on the server, threading children through three layers and hoisting state into the URL, when a 'use client' at the top of a small subtree would have cost almost nothing. The model rewards keeping client code at the leaves, not eliminating it. When a screen is genuinely an application, a board with drag and drop, an editor, a live dashboard, fighting for server rendering buys you complexity and nothing else.
My default client-server split
My default is boring and it holds. Pages, layouts, and anything data-shaped stay on the server: lists, detail views, navigation, static content, anything that reads and renders. Client components live at the leaves and earn their place by doing something only a browser can do: a dialog, a form with optimistic state, an animation, a combobox. The boundary sits as low in the tree as the interaction allows. When a card needs a hover effect, the card body stays on the server and a thin client wrapper handles the hover, not the other way round.
React 19 made the leaves cheaper to hold. Server actions mean a form can live in a server component and post mutations without a hand-rolled API route, with useActionState handling pending and error states in a small client island. The React compiler removed most of the memoisation ritual that used to make client components feel heavy to maintain, so I no longer dread the ones I genuinely need. The result is that on a typical screen the client code is a handful of focused files, each doing one interactive job, and none of them fetching data.
The split maps cleanly onto real products. In the tele-sales CRM I built, the customer history, order list, and parcel tracking views are server components reading straight from the database and the ERP sync layer. The softphone, the live call notes, and the messaging composer are client islands, because a ringing phone is about as interactive as UI gets. Neither side leaks into the other. When I review a pull request there, the first thing I check is whether a new 'use client' directive sits at a leaf or is quietly pulling a whole page across the boundary.
Caching is the real story
After a few years with this model I have stopped thinking the hard question is where a component renders. The hard question is when it renders, and for whom. A server component is just a function; the complexity is whether its output is computed per request, shared across users, revalidated on a timer, or invalidated by a mutation. Get that wrong and you serve one user's data to another, or hammer a database for content that changes weekly. Every production incident I have had with the App Router traces back to caching semantics, never to rendering location.
Next.js has been honest about this lately. The early App Router cached fetches implicitly, and developers were rightly confused about why stale data kept appearing. The 'use cache' directive flips the default: nothing is cached until you say so, and when you say so you mark the function, give it a cache life, and tag it for invalidation. I like it because the caching decision now lives in the code where the data is read, reviewable in a diff, instead of in a mental model of framework defaults that shifted between versions.
So my pragmatic take ends somewhere unfashionable: server components are the easy part. Put data-shaped UI on the server, keep interaction at the leaves, and most rendering decisions make themselves. The real effort belongs in the cache design: which reads are per user, which are shared, which tags each mutation must invalidate. I write that down before I write the components, the same way I would design tables before queries. Teams that skip it end up debugging staleness in production. Teams that do it get the thing the model promised all along: fast pages that are simply correct.