← Back to homereact

Server Components, Settled: How We Architect Next.js Apps Now

← All writing

React Server Components had one of the rockiest launches of any major React feature. The App Router shipped, half the ecosystem broke, everyone argued on Twitter for two years, and a lot of teams quietly pinned themselves to the Pages Router and waited. That period is over. The mental model has stabilized, the caching story has been rewritten into something you can reason about, and the patterns that survived production are consistent across teams. We build client products on Next.js at Luminary — our own site is an App Router app with exactly the architecture described below — and at this point RSC isn't a controversial paradigm; it's the boring, settled default. This is the writeup we wish we'd had earlier: the model that clicked, the patterns we keep, the caching layers named precisely, and the parts that still hurt.

Illustration: stagehands passing set pieces through a glowing doorway to the stage

The mental model that finally clicked

Most of the early confusion came from framing server components as "React components that run on the server," which invites the wrong question: when does this re-render? The framing that actually works is:

Your app is a server-rendered document. Client components are islands of interactivity embedded in it.

A server component is closer to a template than to a component in the React-of-2019 sense. It runs on the server (at build time, at request time, or when a cache revalidates), produces a serialized tree, and never runs again on the client. It has no state, no effects, no event handlers. It can be async. It can read the database directly. It never ships its own JavaScript to the browser.

A client component is the React you already know: state, effects, handlers, hydration. The "use client" directive doesn't mean "renders only on the client" — client components still server-render their initial HTML — it means "this module and everything it imports gets bundled and shipped to the browser."

Once you hold that model, the design question for every component becomes simple: does this need to respond to the user after the page loads? If no — and for most of a typical marketing page, dashboard shell, article body, or settings screen, the answer is no — it's a server component and costs the client nothing. If yes, it's a client island, and your job is to make that island as small as possible.

The inversion that took the ecosystem a while to accept: server is the default, client is the opt-in. Teams that fought this — sprinkling "use client" at the top of every file to make the errors go away — ended up with an SPA that pays App Router complexity tax for none of the benefit. Teams that embraced it ship dramatically less JavaScript and stop thinking about loading spinners for data that was available on the server all along.

Where the ecosystem landed

The backlash years produced real criticism, and it's worth being honest about which parts stuck:

  • "The caching is inscrutable" — was true. Implicit fetch caching was the single biggest source of production surprises we saw in the Next.js 14 era. Next.js 15 flipped the defaults (fetch and GET route handlers are uncached unless you opt in), and Next.js 16's Cache Components made caching fully explicit. This complaint has mostly been engineered away.
  • "The ecosystem isn't ready" — was true, now mostly isn't. Major UI libraries ship "use client" in their published files, data libraries have first-class RSC stories, and "how do I use this charting library" has a boring, known answer: wrap it in a client file.
  • "It's too complicated for what I'm building" — remains true for a meaningful class of apps. More on that below.

Meanwhile, RSC stopped being a Next.js-only bet: other frameworks build on the same React APIs, and React's own docs treat server components as first-class. The primitive won; the arguments now are about framework ergonomics, not legitimacy.

Component architecture: "use client" at the leaves

The single highest-leverage architectural rule we enforce: push "use client" as far down the tree as it will go.

"use client" is a boundary marker, and it's transitive — everything a client component imports becomes client code. Put it at the top of a page and you've opted the entire page, and every component it touches, into the bundle. Put it on a 40-line widget and that's all the browser pays for.

Extract the widget, don't promote the section

The most common failure mode: a section needs one interactive element — a toggle, a canvas, a filter — and someone marks the whole section "use client" because it's the file they're already editing. On our own site, the hero section is a server component; the animated canvas inside it is a separate HeroCanvas client component. The FAQ section is server-rendered; each accordion item is a small client island. This is a rule in our CLAUDE.md precisely because the lazy path is always available and always wrong:

// components/Services.tsx — server component, no directive
import { getServices } from "@/lib/content";
import { ServiceCardSpotlight } from "@/components/ServiceCardSpotlight";

export default async function Services() {
  const services = await getServices();
  return (
    <section id="services">
      <h2>What we build</h2>
      {services.map((svc) => (
        // The interactive spotlight effect is the only client code here.
        <ServiceCardSpotlight key={svc.id}>
          <h3>{svc.title}</h3>
          <p>{svc.description}</p>
        </ServiceCardSpotlight>
      ))}
    </section>
  );
}
// components/ServiceCardSpotlight.tsx — the leaf that hydrates
"use client";

import { useRef, type ReactNode } from "react";

export function ServiceCardSpotlight({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);

  function onPointerMove(e: React.PointerEvent) {
    const rect = ref.current!.getBoundingClientRect();
    ref.current!.style.setProperty("--x", `${e.clientX - rect.left}px`);
    ref.current!.style.setProperty("--y", `${e.clientY - rect.top}px`);
  }

  return (
    <div ref={ref} className="svc-card" onPointerMove={onPointerMove}>
      {children}
    </div>
  );
}

Note the children trick: a client component can render server-rendered children it receives as props. The spotlight wrapper hydrates; the card content inside it stays server-only. This is how you interleave — client components can't import server components, but they can compose them.

The boundary as a design tool

After a while you stop thinking of the server/client split as a constraint and start using it as an architectural instrument:

  • Secrets stay behind the boundary by construction. A server component can hold an API key or query the database; nothing it doesn't explicitly pass as props can leak. (Enforce this with import "server-only" in modules that must never cross.)
  • Bundle size becomes a review question, not an audit. A PR that moves "use client" up the tree is visible in the diff and should be challenged in review.
  • Props across the boundary are a serialization contract. They must survive the RSC wire format — plain objects, arrays, strings, numbers, Dates, Maps, Sets are fine; class instances, functions (other than Server Actions), and ORM row objects with prototype methods are not. Being forced to define that contract usually improves the design: pass { id, title, publishedAt }, not the Prisma entity.

Data fetching without the ceremony

The App Router deleted an entire genre of code. No getServerSideProps, no useEffect-fetch-setState waterfalls, no client cache library required for read paths. A server component just awaits:

// app/work/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getProject, getRelatedProjects } from "@/lib/projects";

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // params is a Promise in Next 15+
  const project = await getProject(slug);
  if (!project) notFound();

  const related = await getRelatedProjects(project.tags);
  return <ProjectView project={project} related={related} />;
}

Three things to actually understand here:

Request memoization. React deduplicates identical fetch calls within a single render pass. If your layout, page, and a nested component all call getUser() backed by the same fetch, one request goes out. This means you don't need to prop-drill data purely to avoid duplicate fetches — call the function where the data is needed. For non-fetch data access (database clients), wrap the function in React's cache() to get the same per-request deduplication.

Parallel vs waterfall. The example above has a hidden waterfall: getRelatedProjects waits for getProject even though only the tags are needed. Sequential awaits in one component are the most common self-inflicted App Router performance bug. Start promises early, await late:

const projectPromise = getProject(slug);
const settingsPromise = getSiteSettings();
const [project, settings] = await Promise.all([projectPromise, settingsPromise]);

Or better: let components parallelize for you. Two sibling server components that each fetch their own data render concurrently. Component-level data fetching isn't just ergonomics — it's the parallelism model.

The fetch cache story. This is where the scar tissue is. In early App Router versions, fetch was cached by default and teams shipped stale data without realizing they'd opted into anything. Since Next.js 15 the default is uncached: a plain fetch in a server component runs on every request. You opt into caching explicitly — fetch(url, { next: { revalidate: 3600, tags: ["projects"] } }) — or, in the Cache Components world, with "use cache". The current defaults are the right ones: dynamic until proven cacheable.

Server Actions vs API routes

Server Actions (functions marked "use server") are the App Router's answer to mutations, and they're genuinely good for their intended job: form submissions and UI-initiated writes.

// app/contact/actions.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { rateLimit } from "@/lib/rate-limit";

const schema = z.object({
  email: z.string().email(),
  message: z.string().min(10).max(5000),
});

export async function submitInquiry(prevState: unknown, formData: FormData) {
  // 1. Actions are public endpoints. Gate them like one.
  const ok = await rateLimit("inquiry");
  if (!ok) return { error: "Too many requests." };

  // 2. Never trust the input — validate everything.
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: "Invalid submission." };

  await saveInquiry(parsed.data);
  revalidatePath("/admin/inquiries");
  return { success: true };
}

Paired with useActionState and a plain <form action={submitInquiry}>, this progressively enhances: the form works before hydration, and even with JavaScript disabled, because under the hood it's an HTML form POST. That's a real accessibility and resilience win that client-only mutation libraries can't match.

The security pitfall that bites real teams: a Server Action is not a private function. The compiler turns every exported action into a public, unauthenticated POST endpoint with a stable ID. Anyone can invoke it with any arguments, regardless of which page "uses" it or what your UI disables. So every action must, internally: authenticate the caller, authorize the specific operation, and validate every argument. Treat "use server" at the top of a file as equivalent to app.post("/public-endpoint", ...) — because that's what it is. Next.js does dead-code-eliminate unused actions and encrypts closed-over variables, but neither of those is an authorization system.

When we still write API routes (route.ts): anything that isn't a same-app UI mutation. Webhooks, endpoints consumed by mobile apps or third parties, streaming responses (our AI chat endpoint is a route handler for exactly this reason), anything needing custom status codes, content types, or CORS. Actions are also serialized per-client — they queue rather than run concurrently — so high-frequency or long-running calls belong in routes. Rule of thumb: Server Actions are RPC for your own forms; API routes are your actual HTTP surface.

The caching layers, named

Most "Next.js caching is confusing" pain comes from not naming which layer you're fighting. There are four:

LayerWhereWhat it cachesYou control it with
Request memoizationServer, per requestDuplicate fetch/cache() calls in one renderAutomatic; cache() for non-fetch
Data cacheServer, persistentIndividual fetch/function resultsnext.revalidate, tags, "use cache"
Full route cacheServer, persistentRendered HTML + RSC payload of static routesStatic vs dynamic rendering, revalidate, PPR
Router cacheClient, in memoryVisited/prefetched route payloadsrouter.refresh(), staleness config

ISR is still the workhorse for content that changes on someone else's schedule — our blog pulls from an external feed with export const revalidate = 3600 and nobody thinks about it. Tag-based invalidation (revalidateTag after a mutation, tags declared at fetch time) is the workhorse for content you mutate.

Cache Components and PPR: where this is heading

Next.js 16 (October 2025) shipped Cache Components as the opt-in successor to the implicit model, and it's the clearest expression yet of what the caching story should have been from the start. You enable cacheComponents: true in next.config.ts, and the rules become: nothing is cached unless you say so, and you say so with a directive:

// lib/projects.ts
"use cache";

import { cacheLife, cacheTag } from "next/cache";

export async function getFeaturedProjects() {
  cacheLife("hours");
  cacheTag("projects");
  return db.project.findMany({ where: { featured: true } });
}

"use cache" works at the file, component, or function level; cacheLife sets the revalidation profile; cacheTag plus updateTag/revalidateTag handles invalidation. Crucially, Cache Components subsumes Partial Prerendering: the old experimental.ppr flag is gone, and PPR is simply how cached and uncached parts of a route compose. A route becomes a statically-served shell (the cached parts) with dynamic holes that stream in at request time, per Suspense boundary. Static-vs-dynamic stops being a per-route decision and becomes per-component — which is the granularity it always should have been.

We're adopting Cache Components on new builds and migrating existing ones opportunistically. If you're starting a Next.js app today, start with it enabled; you'll skip an entire generation of caching folklore.

Streaming and Suspense in practice

Suspense boundaries in the App Router aren't a loading-spinner convenience — they're the unit of streaming. Everything above a boundary flushes immediately; everything inside it streams when its data resolves. loading.tsx is just an implicit boundary around the page.

The practical craft is boundary placement, and it's a product decision as much as a technical one:

  • Wrap the slow, uncacheable thing — not the page. Nav, headline, and layout should never wait for a recommendations query.
export default function DashboardPage() {
  return (
    <>
      <DashboardHeader />         {/* fast, flushes immediately */}
      <Suspense fallback={<StatsSkeleton />}>
        <RevenueStats />          {/* slow warehouse query, streams in */}
      </Suspense>
      <Suspense fallback={<TableSkeleton rows={8} />}>
        <RecentOrders />
      </Suspense>
    </>
  );
}
  • Independent boundaries stream independently. Two slow widgets in separate boundaries pop in as each resolves; in one boundary, the slower one holds both hostage. Group by "should these appear together?", not by markup convenience.
  • Fallbacks must be dimensionally honest. A skeleton the wrong height converts your streaming win into a layout-shift penalty. We size skeletons to match real content and treat CLS regressions in streamed sections as bugs.
  • Don't over-fragment. A page that assembles itself from eight staggered skeletons feels broken, not fast. Two or three meaningful boundaries per view is a good ceiling.

What still hurts

Settled doesn't mean painless. Three categories still cost us real hours:

Serialization errors. "Functions cannot be passed directly to Client Components" is a fine error the first time; less fine when it's a Date-formatting closure buried three levels deep in props, or an ORM entity that looks like a plain object but isn't. The stack traces point at the boundary, not at the offending prop's origin. Our mitigation is architectural: define explicit DTO types at every boundary and map to them at the source. Boring, effective.

Hydration mismatches. The classics — locale-dependent date formatting, typeof window branches that change output, randomness, browser extensions mutating the DOM before React attaches — still produce the same intimidating wall of red. React's error output now diffs the mismatch, which helps, but the debugging experience remains the worst in the stack. Anything inherently client-varying (relative timestamps, for example) goes in a client component that renders a stable placeholder first, updated after mount.

Third-party libraries that assume a client. Better than it was, not solved. Charting, maps, rich-text editors, and anything built on a runtime CSS-in-JS library still need to live behind your own "use client" wrapper file — and occasionally behind a dynamic import when the library touches window at module scope. The pattern is known and mechanical now, but it's still a tax, and it still surprises developers who expect npm install to be the whole job.

Honorable mention: remembering where code runs while debugging. console.log in a server component prints to your terminal, not the browser — staring at an empty devtools console is a rite of passage.

When you don't need any of this

An unpopular opinion during the framework wars, now just an opinion: plenty of apps shouldn't use RSC at all.

An internal dashboard behind a login has no SEO requirement, no anonymous first paint to optimize, and users with warm caches on fast connections. A Vite SPA with React Router and TanStack Query is simpler to build, simpler to debug, deployable as static files in front of any API, and completely free of server/client boundary discipline. The same goes for genuinely local tools, editors, and anything canvas-heavy where the document model isn't the point.

Our actual decision line: RSC earns its complexity when the first, unauthenticated, content-bearing paint matters — marketing sites, e-commerce, docs, blogs, anything indexed or shared — or when server-side composition (secrets, per-request data, heavy markdown/MDX pipelines) removes a whole class of client code. When neither applies, we say so in the proposal and ship the SPA. Choosing the boring architecture is a service we charge for.

Migrating from the Pages Router

For teams still holding Pages Router apps — and there are many, entirely functional ones — the migration advice that has held up:

  1. Don't rewrite; coexist. Both routers run in one app. Move route by route, starting with low-risk, high-read pages (marketing, docs), leaving complex authenticated flows for last.
  2. Do the mechanical layer first. _app.tsx/_document.tsx become app/layout.tsx; next/router becomes next/navigation (useRouter no longer exposes query — use useParams/useSearchParams); getServerSideProps bodies move into async server components; getStaticProps + revalidate becomes cached data access with the same semantics.
  3. Resist the shim. Marking every migrated page "use client" "for now" technically works and strategically fails — App Router complexity with Pages Router bundle sizes, and the temporary directive becomes permanent. Migrate the data fetching for real or don't migrate that page yet.
  4. Jump straight to the current caching model. Land on Next.js 16 with Cache Components enabled rather than the implicit-caching middle generation you'd only have to relearn.
  5. Budget for the long tail. The last 15% — auth edge cases, that one page with seven data dependencies, the analytics wrapper from 2021 — takes as long as the first 85%. Plan it as a background workstream, not a sprint.

Takeaways

  • The model is: server-rendered document, client islands. Ask "does this respond to the user after load?" — that answer places every component.
  • Push "use client" to the leaves. Extract the interactive widget; never promote the section. Use children to pass server-rendered content through client wrappers.
  • The boundary is a design tool: secrets stay server-side by construction, bundle growth becomes reviewable, and serialization forces honest data contracts.
  • Fetch in the component that needs the data; rely on request memoization; kill waterfalls with early promise creation and sibling components. Since Next.js 15, nothing is cached unless you ask.
  • Server Actions are for your own forms and progressively enhance for free — but every action is a public POST endpoint: authenticate, authorize, and validate inside each one. Real HTTP surfaces (webhooks, mobile, streaming) stay in route handlers.
  • Learn the four cache layers by name. On new projects, enable Cache Components and use "use cache" + tags; PPR is now just how cached and dynamic parts compose.
  • Place Suspense boundaries around slow data, keep fallbacks dimensionally honest, and don't shatter the page into a skeleton mosaic.
  • Budget real time for serialization errors, hydration mismatches, and client-assuming libraries — mitigate with DTOs at boundaries and thin client wrapper files.
  • If there's no anonymous first paint to win, a Vite SPA remains a professional choice. Migrating from the Pages Router? Coexist, migrate route by route, and never ship the all-"use client" shim.

Enjoyed the read? We build this stuff for clients too.

Start a project