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.
// ✗ 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>
);
}// ✓ 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.
'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.
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.
They are a render, so they can be slow
An
awaitin 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.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:
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 kind | Strategy | How |
|---|---|---|
| Marketing, docs, blog | Static | dynamic = 'error' plus generateStaticParams |
| Content that changes hourly | Static + revalidate | export const revalidate = 3600 |
| Per-user dashboards | Dynamic | Read the request, and accept the cost knowingly |
| Mixed page, one live figure | Static shell | Suspense boundary around the live part only |
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:
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.
The build
Ran once, at deploy. Everything static lives here. Ask: does this change less often than we deploy?
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.
The data cache
fetchresults, keyed and tagged. Ask: will two different pages want this same response?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:
// 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 is | Where it belongs | Why |
|---|---|---|
| Server data | Server Component + cache | It was never client state; it was a cached read |
| Filters, sort, page, query | The URL | Shareable, survives reload, works with back |
| Form values in flight | The form element | Uncontrolled inputs plus a Server Action |
| Open/closed, hovered, focused | Local useState | Nothing else needs to know |
| Cross-cutting session state | A client store | Genuinely global, genuinely client |
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.
'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.
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, utilitiescomponents/ 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.
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:
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.
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
Push the client boundary down, and use children to wrap server content
A
"use client"on a layout is a subtree, not a component.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.
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.
Put filters and queries in the URL
Almost everything else people call state turns out to be server data or form state.
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.