Next.js 15 server components in production: patterns that survived
The React Server Components patterns that held up under real traffic across NIO, Geely and Trivandi — and the ones I refactored out. Plus what changed in Next.js 16's opt-in caching model.

I run Next.js server components in production on three builds that take real traffic: NIO's regional EV platform, Geely's headless commerce, and Trivandi's marketing site. Different shapes — a configurator with live inventory, a storefront, a content-heavy brochure — but the same architecture underneath. This is the honest account of which React Server Components patterns survived contact with production, which ones I quietly refactored out, and what actually changed when I moved these codebases onto Next.js 16's caching model. No greenfield theory. Just what's still running.
The headline: server components were the right default for all three. But the win wasn't "everything on the server." It was being deliberate about the seam between server and client, and treating the cache as something you design rather than something that happens to you.
Server components as the data boundary
The single most durable decision was making the server component the place where data enters the app, and pushing interactivity to the leaves. On NIO, the vehicle configurator page is a server component. It fetches the trim catalogue, pricing, and regional availability on the server, renders the static shell, and hands a small typed payload to a "use client" island that owns the configure-and-price interaction. The client bundle only contains the interactive part. The 40 KB of catalogue JSON never ships.
This is boring, and it works. A year in, I haven't touched that boundary. The rule I follow now:
- Fetch in server components, mutate in server actions, interact in client islands. If a component doesn't need state, an effect, or a browser API, it stays on the server.
- Pass plain serializable data across the boundary, never functions or class instances. The moment you want to pass a callback down, that's a signal the child should own its own server action instead.
- Keep client islands small and leaf-shaped. A
"use client"at the top of a tree drags the whole subtree to the client. I put it as deep as it will go.

On Geely the same boundary let me keep the product listing fully server-rendered for SEO while the add-to-cart and variant pickers stayed client-side. The category pages index well because the HTML is real, not hydrated in. That is the headless commerce payoff people undersell — see my Hydrogen vs Medusa breakdown for where this fits the broader stack decision, and the commerce build page for how I scope it.
Server Actions: great for mutations, wrong for reads
Server Actions earned their place as the mutation path. Lead capture on Trivandi, the showroom booking flow on NIO, cart mutations on Geely — all server actions. Co-locating the mutation with the component, getting progressive enhancement for free, and skipping a hand-written API route is genuinely less code to maintain.
Where I got burned: I tried using server actions as a general-purpose RPC layer — calling them from useEffect to fetch data on the client, chaining them for read-heavy flows. That was a mistake. Server actions run sequentially and are POST requests under the hood. Using them for reads gave me waterfalls and no caching. I refactored every read back into either a server component fetch or a route handler. The line I hold now: server actions mutate, server components and route handlers read. Nothing else.
The other thing that bit me was validation. A server action is a public endpoint — anyone can call it with any payload. I now validate every action input with Zod at the top of the function, exactly as I would an API route. On the PropertyCheck Laravel backend I would never trust client input, and a server action deserves the same paranoia. If your backend is Laravel or Filament, the server action is just a typed proxy — keep the real authorization on the API.

Cache tags: the part that needed the most thought
This is where the Next.js caching model and a real CMS meet, and where I spent the most time. NIO and Trivandi both run on Sanity. The naive setup re-fetches content on every request, which is fine until you have traffic and a CMS that bills per request.
The pattern that held: tag cached fetches by the content they represent, then invalidate by tag from the CMS webhook. A Sanity document publish fires a webhook, the route handler revalidates the affected tags, and only those pages rebuild. Everything else stays cached. On Trivandi this cut origin fetches sharply and made the editor experience feel instant — publish, see it live in seconds, without rebuilding the world. My longer take on keeping editors happy lives in Sanity in the real world.
The honest trade-off: tag-based revalidation is precise, but you have to be disciplined about tag naming or you end up either over-invalidating (rebuilding too much) or under-invalidating (stale pages). I keep tags coarse and predictable — one per document type plus one per document id — and never get clever.
Cache invalidation is hard because you only notice you got it wrong in production, at the worst possible moment. Make it boring on purpose.
What changed in Next.js 16
The biggest shift is the caching model, and it changed how I write all of this. Next.js 16 moves to Cache Components with the use cache directive: caching is now explicit opt-in rather than implicit. By default everything runs at request time, and you mark what should be cached with use cache plus a cacheLife profile. That inverted the old mental model — in 15 I was constantly opting *out* of aggressive default caching; in 16 I opt *in* to caching the bits that should be static.
Two concrete migration notes that catch people. First, unstable_cache is gone — you convert those calls into use cache functions with cacheTag and cacheLife, and the manual key-parts array disappears because keys are now derived from arguments and closures. Second, there are now two invalidation verbs: revalidateTag still does background stale-while-revalidate, but updateTag refreshes a tag immediately within the same request, which is what you want right after a mutation in a server action. Picking the wrong one is the subtle bug.
One more 16-ism worth flagging, since it lives in this very codebase: middleware is now proxy.ts, not middleware.ts. If you rely on locale routing or draft mode, the matcher config moved with it.
Partial Prerendering, scoped tight
Partial Prerendering — now folded into Cache Components and enabled with cacheComponents: true — is the feature I was most skeptical of and ended up keeping, but only in specific places. PPR serves the static shell of a page instantly and streams the dynamic parts in afterward, wrapped in Suspense. On the NIO model pages, the layout, hero, and spec tables are static and cached; the live regional inventory and "available at your showroom" block is dynamic and streams in. The user sees a complete-looking page immediately, then the live bits fill in.
Where I did not reach for it: pages that are either fully static (most of Trivandi's marketing pages — just cache them) or fully dynamic (a logged-in dashboard — no static shell worth precomputing). PPR shines on the in-between: a mostly-static page with a small live island. Forcing it everywhere added Suspense boundaries that bought nothing. The skill is recognizing when a page actually has that static/dynamic split, and most don't.

What I'd tell someone starting today
If you are building on React and Next.js in 2026, the server components default is correct and stable — this is not bleeding edge anymore. But three things separate a build that ages well from one that fights you:
- Design the server/client seam first. It is the most expensive thing to move later. Get the islands small and the data boundary clean before you write features.
- Server actions mutate, full stop. The moment you are using one for reads, you have taken a wrong turn.
- Treat the cache as architecture. Tag deliberately, invalidate by webhook, keep
cacheLifeprofiles named and few. In 16's opt-in model this is more explicit work up front and far less debugging later.
This is the kind of decision I make as a fractional CTO before a line of feature code gets written, because the seam and the cache strategy are the parts you cannot cheaply undo. If you are scoping a web app and weighing this stack, these are the patterns I would stand behind under load.
FAQ
Are React Server Components production-ready in 2026?
Yes. With Next.js 16 the App Router, server components, and server actions are stable and have been for several major versions. I run them in production on commerce and content sites under real traffic. The "experimental" label that scared people off in the Next.js 13/14 days is gone — Cache Components and PPR are the current stable model, not a preview.
When should I use a Server Action versus a route handler?
Use a Server Action for mutations triggered from your own UI — form submissions, cart changes, bookings — where you want progressive enhancement and co-located code. Use a route handler when you need a real HTTP endpoint: webhooks, third-party callbacks, anything called by a non-Next.js client. Do not use server actions for client-side data fetching; they do not cache and you will create waterfalls.
What changed about caching in Next.js 16?
The model flipped from implicit to explicit. Next.js 16 introduces Cache Components and the `use cache` directive: nothing is cached by default, and you opt specific functions, components, or pages into caching with `cacheLife` and `cacheTag`. `unstable_cache` is replaced by `use cache`, and there are now two invalidation verbs — `revalidateTag` for background revalidation and `updateTag` for immediate same-request refresh.
Do server components hurt SEO?
The opposite — they help. Server components render real HTML on the server, so crawlers see content without executing JavaScript. On Geely's storefront, fully server-rendered category and product pages index reliably because the HTML is complete on first response, not hydrated in afterward. That is one of the strongest practical arguments for the pattern on commerce and content sites.
Should I put every page behind Partial Prerendering?
No. PPR only pays off when a page has a genuine static/dynamic split — a mostly-static page with a small live island, like a product page with live inventory. Fully static pages should just be cached, and fully dynamic pages like authenticated dashboards have no static shell worth precomputing. Forcing PPR everywhere adds Suspense boundaries that buy you nothing.
How do I revalidate the Next.js cache when content changes in Sanity?
Tag your cached fetches by content type and document id, then fire a webhook from Sanity on publish that hits a route handler calling `revalidateTag` for the affected tags. Only the tagged pages rebuild; everything else stays cached. Keep tags coarse and predictable to avoid both over-invalidation and stale pages. This is the setup I run on NIO and Trivandi.
Technologies
Have something like this in mind?
Start a project→

