Server Components, One Year In
What the mental model costs, and where it pays off
Server Components are explained everywhere as a bundle size win, which is true and is not the part that changed how I write React. The part that changed how I write React is that the default flipped. Components are server-side until I say otherwise, and that one inversion rearranged more of my code than any API in the framework.
01The Default Flipped
In the old model every component was a client component and the server was somewhere you fetched from. Now the server is where components live, and the client directive is an opt-out I have to justify. That sounds like a small framing difference and it is not. It changes what a component is allowed to be. A server component can await data directly in its body. It can read from the filesystem, hold a secret, and call an internal service, because none of it ships. What it cannot do is remember anything between renders or respond to a click. So the question I ask while building has shifted from where do I fetch this to how far down the tree can I push the interactivity. The answer is usually further than my instincts suggest. A page that felt like it needed to be interactive often turns out to be a static tree with one interactive leaf, and once you find that leaf the rest of the page gets to stay on the server. That is the actual win. The bundle size improvement is a consequence, not the goal.
02Where the Boundary Goes
The mistake I made repeatedly at the start was putting the client directive too high. A section needs a toggle, so the section becomes a client component, so every card inside it becomes a client component, so the data formatting and the icon set and the date logic all cross into the bundle with it. The directive is not a property of a component, it is a property of a subtree. What works better is treating interactivity as a wrapper. The interactive shell is a client component that takes children, and the children are server components passed through it. The state lives in the wrapper, the content stays on the server, and the boundary sits exactly where the behavior starts rather than where the feature starts. On this site the blog listing does this: the grid and list toggle and the debounced search input are a client shell, and every card rendered inside it is a server component that never enters the bundle. The pattern took me a while to internalize because it looks backwards. You are passing rendered output into an interactive component rather than importing it there.
03Data Fetching Got Boring
This is the part I did not expect to enjoy as much as I do. Fetching in a server component is an await in the component body. There is no effect, no dependency array, no loading state, no cleanup on unmount, no race between two in-flight requests where the slower one resolves last and overwrites the newer data. That last one is a bug I have written more than once and will not write again in this model. What replaces it is a different discipline. Because every await in the tree is a point where rendering can pause, sequential awaits in nested components turn into a waterfall that nobody notices until the page feels slow. The fix is the same one that always applies: start the requests together and await them together, rather than one after another. And because the framework caches and deduplicates requests within a render pass, asking for the same data in three components is genuinely fine, which removes the usual pressure to hoist everything into a single top-level fetch and drill it down through props.
04The Sharp Edges
Error messages at the boundary are still the roughest part. Passing a function to a client component fails at runtime with a message that describes the serialization problem rather than pointing at the prop you passed, and finding it means reading the tree yourself. The rule is simple enough once you know it, everything crossing the boundary must be serializable, but the tooling does not help you find the violation. Context is the other adjustment. A server component cannot consume it, so anything genuinely global, a theme or a dictionary, has to be provided by a client component high in the tree and read only from the client side. On this site the translations are passed to server components as plain arguments and to client components through a provider, which means the same data arrives by two different paths. It works, and it is not elegant. The last one is a habit, not a bug. It is easy to forget that a server component reruns on every request and to put something expensive in it that would previously have been memoized on the client.
Takeaways
Server Components are less a feature to adopt than a default to get used to. Push the client boundary as far down the tree as it will go, pass server-rendered children into interactive shells rather than importing them, and treat serialization as a real constraint rather than a technicality. The payoff is not primarily the smaller bundle, it is that most of the code stops needing to think about the client at all.