Skip to content

Writing

Architecting large-scale Next.js applications: Server Components, caching and state

How to structure a Next.js App Router codebase that stays fast as it grows — where the client boundary belongs, what Server Components actually cost, which caching layer answers which question, and the state patterns that survive a hundred routes.

9 min read

The App Router rewards a particular shape of codebase and punishes another, and the difference does not show up until the application is large enough that changing it is expensive. This is the shape we build to, and the reasoning behind each decision.

Everything here is from production work and from this site, which runs on Next.js 16 with the React Compiler and ships zero route-level JavaScript on most pages.

Server Components are the default, and that is an architectural stance

The single most consequential decision in an App Router codebase is where "use client" goes. Not whether — where.

A client boundary is contagious downward: every component imported by a client component becomes part of the client bundle, whatever it contains. So a "use client" at the top of a layout does not mark one component as interactive, it marks a subtree.

In practice that means the boundary belongs on things like a navigation dropdown, a form, a carousel, a search field. It does not belong on a section, a card, a layout, or anything whose only reason for existing is arrangement.

tsx
// ✗ The whole section, and everything it imports, is now client code.
'use client';
export function Pricing({ plans }) {
  const [selected, setSelected] = useState(plans[0]);
  return (
    <section>
      <PricingHeader />        {/* pure presentation, now shipped */}
      <PlanGrid plans={plans} onSelect={setSelected} />
      <PlanDetail plan={selected} />
    </section>
  );
}
tsx
// ✓ Only the selector is interactive, so only the selector crosses.
export function Pricing({ plans }) {
  return (
    <section>
      <PricingHeader />        {/* stays on the server */}
      <PlanSelector plans={plans} />   {/* 'use client' lives here */}
    </section>
  );
}

Children are the escape hatch

When something interactive genuinely has to wrap server content — a tab panel, a disclosure, a drawer — pass the server content as children rather than importing it inside the client component. Children are rendered on the server and handed across the boundary as already-built elements.

tsx
'use client';
export function Disclosure({ summary, children }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>{summary}</button>
      {open ? children : null}   {/* server-rendered, never bundled */}
    </div>
  );
}

This one pattern is the difference between a client boundary costing a few kilobytes and costing a page.

What a Server Component actually costs

Server Components are not free, and the cost is not where people look for it.

  1. They do not ship JavaScript — but they do ship a payload

    The RSC payload describes the rendered tree, and it is sent alongside the HTML. A page with a great deal of inlined content pays for that content twice: once in HTML, once in the payload. On this site the technology grid carries 72 inline brand marks, which took the home page from 92.6 kB to 159.9 kB gzipped for exactly that reason.

  2. They are a render, so they can be slow

    An await in a Server Component blocks that subtree's HTML. Fetch in parallel, and put anything genuinely slow behind its own <Suspense> so it streams rather than holding the document.

  3. They cannot hold state, and that is the point

    If a component needs useState, it is a client component. Fighting that with a server-side store is a sign the boundary is in the wrong place.

Rendering strategy: pick per route, and make the wrong choice fail

The most useful line in a Next.js route file is one most codebases never write:

ts
export const dynamic = 'error';

That makes the route fail the build if anything in it reads a request — cookies(), headers(), dynamic searchParams. Without it, one accidental dynamic read silently converts a static page into a server-rendered one, and nobody notices until the hosting bill or the TTFB does.

Every marketing route on this site carries it. That is how we know they are static, rather than believing it.

Route kindStrategyHow
Marketing, docs, blogStaticdynamic = 'error' plus generateStaticParams
Content that changes hourlyStatic + revalidateexport const revalidate = 3600
Per-user dashboardsDynamicRead the request, and accept the cost knowingly
Mixed page, one live figureStatic shellSuspense boundary around the live part only
Which strategy answers which question. The default should always be the least dynamic thing that works.

generateStaticParams plus dynamicParams = false

For any route with a finite set of slugs — posts, case studies, product pages — enumerate them and turn off the fallback:

ts
export const dynamicParams = false;
 
export function generateStaticParams() {
  return getDocuments('blog').map((doc) => ({ slug: doc.slug }));
}

Now a slug outside that list is a 404 rather than an attempted render. This matters more than it looks: without it, a route that reads a file path assembled from a URL segment is reading a request-derived path, which is a class of bug worth designing out entirely.

Caching: four layers, four different questions

The most common Next.js caching mistake is treating it as one thing. It is four, and each answers a different question.

  1. The build

    Ran once, at deploy. Everything static lives here. Ask: does this change less often than we deploy?

  2. Revalidation (ISR)

    A static page regenerated on a timer or on demand. Ask: is a value up to N seconds stale acceptable? For a download counter, yes. For a stock level, no.

  3. The data cache

    fetch results, keyed and tagged. Ask: will two different pages want this same response?

  4. The router cache

    Client-side, per navigation. Ask: should going back re-fetch? Usually not, and this is why the back button feels instant.

Tag-based invalidation is what makes the middle two usable at scale, because it decouples what changed from which pages showed it:

ts
// Where the data is read
const res = await fetch(url, { next: { tags: ['pricing'] } });
 
// Where the data changes — a webhook, a Server Action, a CMS callback
revalidateTag('pricing');

Without tags you end up with either a short timer everywhere — which is a cache that mostly misses — or a manual list of paths that goes stale the first time somebody adds a route.

A live figure does not have to make a page dynamic

This site reads download counts from two package registries at build and refreshes them hourly with revalidate. The page stays static, the number stays current within an hour, and there is a hand-verified fallback so a registry outage degrades the figure rather than failing the deploy. That shape — static page, timed refresh, explicit fallback — covers the large majority of "but it needs live data" requirements.

State: most of it is not state

In an App Router codebase, three quarters of what a client-side store used to hold is now something else. Sorting what remains is the difference between a codebase that scales and one that acquires a global store.

What it isWhere it belongsWhy
Server dataServer Component + cacheIt was never client state; it was a cached read
Filters, sort, page, queryThe URLShareable, survives reload, works with back
Form values in flightThe form elementUncontrolled inputs plus a Server Action
Open/closed, hovered, focusedLocal useStateNothing else needs to know
Cross-cutting session stateA client storeGenuinely global, genuinely client
Where each kind of state belongs. Reach for a client store only for the last row.

Put filter state in the URL. It is the single highest-value pattern in this list. A search query kept in React state looks identical to one kept in ?q= until somebody reloads, shares the link, or presses back — and then it is simply missing.

tsx
'use client';
const params = useSearchParams();
const router = useRouter();
const query = params.get('q') ?? '';
 
// `replace`, not `push` — otherwise back walks through every keystroke.
router.replace(`${pathname}?q=${encodeURIComponent(next)}`, { scroll: false });

Structure that survives a hundred routes

The directory layout matters less than two rules about direction of dependency.

code
src/
  app/            routes only — composition, metadata, nothing reusable
  features/       one folder per page or domain; may import components/
  components/     shared and generic; may NOT import features/
  content/data/   typed content, no I/O, testable
  lib/            fetching, env, utilities
  1. components/ never imports features/

    A shared component that knows about one page is not shared. This single rule prevents the slow collapse where everything imports everything.

  2. Data and I/O are separate modules

    The module that reads the disk or the network carries server-only; the module holding the rules and the shapes does not. Otherwise the rules cannot be unit tested — and those rules are exactly the ones that fail silently.

That second one has a sharp edge worth knowing: server-only throws on import outside a Server Component, which is correct at build and a wall in front of any test or spec that just wants to read a constant. Keep the constants out from behind it.

What to measure, and what the numbers mean

Two budgets, tracked separately, because they move for different reasons:

  1. The framework floor

    Next 16 plus React 19 carry a fixed baseline that no application change removes — roughly 130 kB gzipped on a route with zero client components. Budgeting below it is budgeting for something impossible, and we did exactly that in an early plan before measuring.

  2. Route-added JavaScript

    Everything above the floor is yours, and it is the number worth defending. Measure real transfer size in a browser rather than parsing build manifests, whose shape changes between bundler versions.

The short version

  1. Push the client boundary down, and use children to wrap server content

    A "use client" on a layout is a subtree, not a component.

  2. Write dynamic = 'error' on every route that should be static

    Then an accidental request read fails the build instead of quietly changing your hosting profile.

  3. Choose the caching layer by the question it answers

    Build, revalidate, data cache, router cache. Tag your fetches so invalidation is not a list of paths.

  4. Put filters and queries in the URL

    Almost everything else people call state turns out to be server data or form state.

  5. Enforce the dependency direction

    components/ never imports features/. Keep rules out from behind server-only so they can be tested.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.