Engineering
Server Components in Production: Notes From Our First Three Builds
We moved our default stack to the Next.js App Router earlier this year, which means we have now shipped three production client builds on React Server Components. Enough mileage to have opinions that are not just launch-week enthusiasm. The short version: the mental model is genuinely better for the kind of content-heavy, commerce-adjacent sites a studio like ours builds, but the ecosystem is still catching up, and you should budget for that.
Where RSC earns its keep
The wins are mostly about what stops shipping to the client. On our most recent build, a marketing and commerce site for Northwind, the product listing pages render entirely on the server: CMS content, pricing, inventory badges. The client bundle for those routes carries the cart, a filter panel, and almost nothing else. First load JS dropped by roughly 60 percent compared to the equivalent pages on their old pages-router build.
Data fetching colocation is the other quiet win. Fetching inside the component that needs the data, with request deduplication handled for you, deletes a whole category of prop-drilling and loader ceremony. Our components got smaller and our data flow got easier to read in review.
// A server component: async, fetches its own data, ships zero JS
export default async function ProductRail({ collection }: { collection: string }) {
const products = await getCollection(collection, { revalidate: 300 });
return (
<ul className="rail">
{products.map((p) => <ProductCard key={p.id} product={p} />)}
</ul>
);
}
Where it still fights you
Being honest about the rough edges, because launch posts never are:
- The client boundary is a design decision now, and juniors will get it wrong. A single careless "use client" at the top of a layout drags half your tree into the bundle. We caught this in review twice.
- Most animation and interaction libraries assume client rendering. Expect wrapper components and a few awkward islands while vendors catch up.
- Caching behavior is powerful but implicit. Fetch-level caching, route segment config, and revalidation interact in ways that are hard to hold in your head. We now document the caching intent of every route in the PR description, which sounds bureaucratic and has saved us three incidents.
- Debugging serialization errors ("functions cannot be passed to client components") is a rite of passage. It happens once per engineer, then never again.
How we decide what runs where
Our current rule of thumb is simple: server by default, client only for state the user can see change. Hover states, carts, form inputs, anything animated. Everything else, including most of what teams reflexively make interactive, renders once on the server and gets cached.
The deeper shift is organizational. RSC pushes rendering decisions into architecture conversations that used to be trivial. That is a cost. It is also why performance on these builds is the best we have ever shipped without heroics: the framework makes the fast path the default path, and you have to actively opt out of it.
Would we go back? No. But if you are a team considering the jump mid-2023, do it on a greenfield project, not a migration. Give the team one build to internalize the boundary model before you bet a deadline on it. The paradigm is right. The scar tissue just is not free.
Building something this could apply to?
We take on a small number of flagship projects each quarter.
Start a project