All insights
Full Stack

Why we build with Next.js 15 + React Server Components

How React Server Components changed our data-fetching model, what we gained in production, and when we still reach for a classic SPA.

May 12, 202614 min readArventra Technologies team

We've been building production React applications for nearly a decade — from class components through hooks, Context, Redux, Zustand, and every state-management library in between. The switch to Next.js 15 with React Server Components (RSC) and the App Router is the biggest shift in that timeline, and it's now the default starting point for almost every full stack project we ship at Arventra.

This post explains exactly why: what RSC actually changes under the hood, the production wins we measured, the tradeoffs that bit us on real client work, the code patterns that make it click, and the specific cases where we still reach for a classic single-page app. If you're a CTO or engineering lead weighing a rewrite, this is the briefing we'd give you in a kickoff call.

The mental model: server-first, client where it matters

In a traditional React SPA, every component ships to the browser as JavaScript. The browser downloads, parses and executes it, then makes API calls to fetch data, then re-renders. The cost compounds with every dependency, every chart library, every date utility you import.

Server Components flip this. Components execute on the server, reach directly into the database, render to HTML, and stream that HTML to the browser — without ever shipping their JavaScript to the client. Client Components (the ones explicitly marked "use client") keep their old job: interactivity, state, effects, event handlers.

The default is server. You opt into client only where you genuinely need it — a button with a click handler, a form that uses local state, a chart that needs the DOM. Everything else stays on the server.

A concrete example

Here is a typical product page in the old model (SPA) vs the RSC model. First, the old way:

// app/product/[id]/page.tsx — SPA style
"use client";
import { useEffect, useState } from "react";

export default function ProductPage({ params }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/products/${params.id}`)
      .then((r) => r.json())
      .then((data) => { setProduct(data); setLoading(false); });
  }, [params.id]);

  if (loading) return <Spinner />;
  return <ProductDetail product={product} />;
}

Now the same page as a Server Component:

// app/product/[id]/page.tsx — RSC
import { db } from "@/lib/db";
import { AddToCart } from "./add-to-cart"; // a Client Component

export default async function ProductPage({ params }) {
  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { reviews: true, variants: true },
  });

  if (!product) notFound();

  return (
    <article>
      <ProductDetail product={product} />
      <AddToCart productId={product.id} />
    </article>
  );
}

Notice what's gone: the API route, the useEffect, the loading state, the client-side fetch, the JSON parse. Notice what's still there: the interactive button (AddToCart) is a Client Component, marked "use client", because it has a click handler. Everything else runs on the server and ships zero JavaScript.

What we actually gained in production

Hard numbers from three live deployments we rebuilt over the last 18 months:

MetricSPA baselineRSC rebuild
JS shipped to client (gzipped)340 KB78 KB
Largest Contentful Paint (p75)2.8s1.1s
Time to Interactive (p75)4.2s1.4s
API routes maintained479
Lighthouse Performance7198

Beyond the metrics, the structural wins:

  • Direct database access from components. No more building a REST or GraphQL layer just to feed the frontend. On our ChequeCourier dashboard, dropping the intermediate API cut roughly 1,400 lines of glue code and removed an entire deploy target.
  • Streaming + Suspense. The shell paints in under 200ms while slower data resolves in parallel. Users see structure immediately instead of a blank spinner. Wrap slow children in <Suspense> and they stream in independently.
  • End-to-end TypeScript. Database types flow through Prisma, through Server Components, into Client Components as serialized props — fully typed, zero hand-written DTOs. Refactors that used to take a sprint take an afternoon.
  • Partial Prerendering (PPR). Static shell cached at the CDN edge, dynamic islands rendered per-request. On Kelvin's marketing site this gave us static-site performance with live, personalized status indicators on the same page.
  • Server Actions for mutations. Form submissions call typed server functions directly — no API route, no client-side fetch wrapper, no manual loading state. The form itself can stay a Server Component.

The tradeoffs that bit us

RSC isn't free. These are the real-world friction points we hit and how we work around them.

The serialization boundary is strict

Anything passed from a Server Component to a Client Component must be serializable. Functions, class instances, Dates with custom prototypes, Maps, Sets — none of these survive the boundary cleanly. The first time you try to pass onClick={handler} from a Server Component you'll get a cryptic error. The fix is structural: keep the handler inside the Client Component, or pass primitive props down.

Mental model overhead

Engineers need to internalize where each component runs. Putting "use client" at the wrong level can quietly turn a server tree into a client tree and erase all the JS savings. Code review discipline matters — we now check the network tab on every PR to make sure the bundle didn't balloon.

Library compatibility

Heavy client libraries (rich text editors, charting, drag-and-drop) need a thin Client Component wrapper. Most modern libraries (Tiptap, Recharts, dnd-kit) handle this fine. A few older ones — particularly anything using legacy Context patterns or singleton state — need replacement.

Debugging is harder

Server errors surface in the terminal, client errors in the browser. Source maps across the boundary have improved but are still imperfect. We standardize on Sentry server + client SDKs to unify the stream.

When we still pick a classic SPA

RSC is not the right answer for everything. We default back to Vite + React SPA (or this site's TanStack Start setup) when the project is:

  • An embedded dashboard served behind an existing API — there's no SEO requirement and no server we control to render on.
  • A real-time collaborative tool (whiteboard, editor, multi-cursor app) where almost every interaction is client-side anyway.
  • A desktop-class internal app with heavy local state, drag-and-drop, offline support, or PWA install flows.
  • A thin client to a polyglot backend (e.g. Python/Go/Rust APIs) where the team isn't writing Node anyway.

For everything else — SaaS dashboards, marketing sites, content platforms, e-commerce, customer portals — RSC wins on performance, SEO, and shipping speed.

Our default Next.js 15 stack today

On new full stack engagements we start with:

  • Next.js 15 — App Router, RSC, Server Actions, Partial Prerendering.
  • TypeScript end-to-end, strict mode on.
  • Tailwind CSS with a per-project design token system.
  • Prisma + PostgreSQL for data (see our data-layer post).
  • NextAuth / Auth.js for authentication, or Clerk when the client wants a hosted UI.
  • Zod for runtime validation on every Server Action.
  • Vercel or Cloudflare for hosting, depending on edge requirements.
  • Sentry + Vercel Analytics for observability from day one.

It's the fastest path from blank repo to a fast, typed, SEO-friendly product we've ever shipped.

Frequently asked questions

Is Next.js 15 production-ready?

Yes. Next.js 15 stable shipped in late 2024 and is running production traffic at every scale, including some of the largest e-commerce sites on the web. The App Router is no longer "new" — it's the default.

Can I migrate an existing Pages Router app incrementally?

Yes. Pages and App Router can co-exist in the same project. We typically migrate route-by-route over 2–6 weeks, starting with low-risk marketing pages and moving toward authenticated dashboards last.

Do Server Components work with my existing REST/GraphQL API?

Absolutely. Server Components can call any API the server can reach — REST, GraphQL, gRPC, third-party SaaS. You just lose the direct-database advantage for those calls, which is fine for legacy systems you can't change.

How does SEO compare to a traditional SPA?

Significantly better. RSC ships fully-rendered HTML on first request, identical to a static site from a crawler's perspective. Combined with the streaming model, both Google and social-media crawlers see complete content immediately.

What's the learning curve for a team that knows React?

About two weeks of friction, then it clicks. The biggest adjustment is unlearning useEffect for data fetching. Once a team internalizes "fetch in the Server Component, pass primitives to Client Components for interactivity", productivity climbs above the SPA baseline.

Ready to build on this stack?

If you're rebuilding on Next.js 15 or starting a new full stack project, our full stack development team has shipped this stack across SaaS, fintech, and content platforms. We can architect the rebuild, run a discovery sprint, or embed engineers directly into your team.

Need a senior team that ships this kind of work?