The Rendering Spectrum

Lesson 1 of 5

Static & Dynamic as a Spectrum

"Next.js takes a different approach: the boundary between static and dynamic is at the component level, not the route level." — Next.js Documentation, "Rendering Philosophy"

If you have written Next.js before and stopped paying attention for a version or two, this is the chapter where your existing knowledge is most likely to be wrong — not out of date at the margins, but wrong about the shape of the thing. The model you probably carry is that a route is either static or dynamic: you either prerendered it at build time or you render it per request, the framework tells you which in the build output, and one dynamic thing anywhere on the page moves the whole route into the dynamic column.

That model was real, it was Next.js for years, and it is no longer how this works. The documentation for it still exists under a telling filename — caching-without-cache-components.md — which is the sort of name a framework gives a guide it expects you to be migrating away from.

The current model is that static and dynamic are properties of components, not routes, and a single response can contain both. A page can have a header that was rendered at build time, a product listing that was cached an hour ago and will refresh independently, and a cart badge that is computed for this specific request, all delivered in one streaming response with no client-side fetch and no splitting the page into separate routes.

This chapter is about that model as a model: where the line can be drawn, what decides which side a given component lands on, and what the finer-grained boundary costs — because it does cost something, and the cost has moved somewhere most tutorials do not look. The four chapters after this one are the mechanics. This one is the shape.

The big question

If a page no longer has to be all-static or all-dynamic, what decides for each individual component — and who pays for the flexibility?

Three places to draw the line

There are only three sensible answers to "where does static end and dynamic begin", and every framework picks one. It is worth seeing them side by side, against a concrete page, because the trade-off is genuinely a trade-off rather than a progression.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The component-level row is not free, and the documentation is unusually direct about where the bill lands: "The trade-off is infrastructure complexity. A finer-grained rendering boundary transfers complexity from application code into the hosting platform." We will come back to that in the last section, because it is the part that decides whether this model actually works where you deploy it.

Everyday analogy

Think about how a restaurant handles a dinner service. The simplest model is a fixed menu cooked in advance: everything is ready before anyone arrives, service is instant, and if you want something changed you come back tomorrow. The next model is cooking each table's order from scratch on arrival — completely flexible, and now every table waits for its slowest dish, including the bread that has been sitting ready since four o'clock. What a good kitchen actually does is neither: the stocks, the sauces, the bread and the prepped vegetables were all done in the afternoon, and only the parts that genuinely cannot be made in advance are cooked to order. The bread arrives immediately, the fish arrives when the fish is ready, and nobody waits for the bread because of the fish. That is component-level rendering, and the analogy carries the cost too. A kitchen that works this way needs more coordination than one doing either extreme — more storage, a system for knowing what is prepped and how old it is, and a way to throw out the batch when something changes. The flexibility is real and so is the machinery it requires.

What decides where a component lands

So if the decision is per component, what makes it? At build time Next.js renders the route's component tree and sorts each component by the APIs it uses. There are four outcomes, and only four.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Those four outcomes are the whole classification, and they are worth memorising in this order: cached, deterministic, streamed, or a build error. Note what is absent — there is no outcome where the framework decides on your behalf to make the page dynamic. That decision was the most common silent regression in the previous model, and it has been converted into a build failure.

The two verdicts that surprise people are the last two rows: <CartBadge /> reading a cookie with no boundary, and <SearchResults /> reading searchParams with no boundary. Both are ordinary, reasonable-looking components. Both fail the build. The fix in each case is a <Suspense> boundary, which is the same fix as the streaming chapter — and this is where that chapter and this one turn out to be one topic.

A little history

The route-level model was not an accident; it was a deliberate simplification that Next.js inherited from its own success. getStaticProps and getServerSideProps, introduced in Next.js 9 in 2019, made the choice explicit and per-page, and their great virtue was that you could look at a file and know what it was. Incremental Static Regeneration arrived a year later to soften the "rebuild to change anything" problem, and it worked, but it worked per route — the unit of caching was the page. Everything after that was pressure on the same seam. Teams kept meeting pages that were 95% shareable and 5% personal, and the available answers were to serve the whole thing dynamically or to punch a hole and fill it from the browser after load, which is how a generation of applications ended up with a spinner in the corner of an otherwise instant page. What is interesting is that the eventual fix required something that had nothing to do with caching: streaming. You cannot deliver a static shell with dynamic holes in one response unless you can send a response in pieces, so the component-level boundary was not really available until React's streaming renderer existed. The feature people wanted for five years was blocked on a capability in a different layer, which is a common shape and worth recognising when you are told something is simply an oversight.

A Suspense boundary does not make anything dynamic

Here is the misconception that produces the most confusion, and it is an easy one to form from the previous section: it looks as though <Suspense> is what moves a component out of the shell. It is not. A boundary is where a fallback goes if the component turns out to need request time. It has no opinion about whether it does.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

That last point catches people writing self-hosted applications against a local SQLite file. The query is synchronous, so it is deterministic, so it runs at build time and its results are frozen into the shell. Nothing is broken — the framework did exactly what the rules say — but the data is as old as your last deploy, and the only signal was that you never told it otherwise.

The engineer's notebook

Two questions now sit at the front of my mind whenever I write a server component in this model, and they have replaced the question I used to ask. I used to ask "is this route static or dynamic", which no longer has an answer. What I ask instead is: does this component's output differ per request, and does it differ per user? Those are two questions rather than one, and the pair maps cleanly onto the mechanics — same for everyone and stable for a while means use cache; genuinely per-request means a boundary; neither means it prerenders on its own and I should stop thinking about it. The second habit is a build-output check. The build tells you which routes were fully prerendered, and reading it after a change is how you notice that a component you added deep in a tree quietly cost a route its shell. That used to be a production discovery; it is now a two-line diff in a build log, and it is only useful if somebody looks. The third is smaller but has saved me twice: when a page renders stale data on a self-hosted deploy and the code looks right, check whether the data source is synchronous. Deterministic means build-time, and synchronous database drivers are deterministic as far as the prerenderer is concerned.

Non-determinism has to be declared

There is one more category, and it is the one that turns an abstract rule into a build failure on code you have written a hundred times. Math.random(), Date.now(), crypto.randomUUID() — functions that return something different on every call. A prerenderer cannot silently bake those in, and it will not silently defer them either. You have to say which you meant.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

connection() is the function to remember here. It is the explicit statement "this component needs a real request", and it is what you reach for whenever a component's output must be per-request but nothing in its code reads a request-specific API.

The bill: infrastructure

Now the honest part, because everything above is upside and the documentation does not pretend the trade is free. Moving the boundary into the component tree takes complexity out of your application code and puts it into the platform that serves your application.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The cache consistency requirement is the one worth dwelling on, because it is the least obvious and the most user-visible when it fails. Revalidation has to regenerate two artefacts — the HTML for a direct visit and the RSC payload for a client-side navigation — and if those drift apart, the same page shows different content depending on how you arrived at it. That is a genuinely confusing bug to receive a report about, and it is a property of the deployment rather than of the code.

In the real world

A team self-hosting behind a load balancer shipped a pricing change and watched roughly a third of visitors keep seeing the old figure for the rest of the afternoon. Refreshing sometimes fixed it and sometimes did not, which is the signature that sends people looking for a browser caching bug. It was not the browser. The revalidation had been triggered on one instance, and the other two were still serving their own in-memory copies of the cached component, because nothing was propagating the invalidation between them. Each instance was internally consistent and the three disagreed, so which price you saw depended on which one your request landed on. The eventual fix was a shared cache so invalidations reached every instance, but the diagnosis is the part worth keeping: the symptom was reported as a caching bug in the application, and no amount of reading the component would have found it, because the component was correct on all three machines. This is exactly the infrastructure requirement the documentation names, and it only becomes visible at the point where you run more than one copy of the process — which is to say, after the staging environment where everything worked.

Common Mistakes

  1. Carrying over the route-level mental model. "Is this page static or dynamic" no longer has an answer. Ask it per component.

  2. Reading a cookie or searchParams with no boundary above it. That is a build error, not a silent downgrade to dynamic rendering — which is the improvement, though it does not feel like one at the time.

  3. Wrapping a synchronous component in <Suspense> and expecting it to become dynamic. A boundary declares where a fallback goes; it does not change what the component needs.

  4. Assuming a synchronous database query gives you fresh data. Synchronous is deterministic, so it runs at build time and freezes into the shell. Use connection() if you need per request.

  5. Calling Math.random() or Date.now() in a component and expecting either behaviour. Declare which you meant: connection() for per-request, use cache to share one value.

  6. Treating <Suspense> as a performance tool only. In this model it is also the mechanism that keeps a route buildable, which is why the streaming chapter and this one are the same subject.

  7. Designing around an edge-served shell without checking your platform provides one. Functional fidelity is binary and widely available; performance fidelity is not.

  8. Forgetting that revalidation must regenerate two artefacts. If HTML and the RSC payload diverge, a direct load and a navigation disagree.

  9. Reading old caching material without checking which model it describes. The previous model has its own guide now; advice about fetch caching defaults written before it will mislead you.

  10. Never looking at the build output. It tells you which routes were fully prerendered, which is the only cheap way to notice a component that quietly cost a route its shell.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
Is a Next.js page static or dynamic?
Model answer — In current Next.js that is not quite the right question, because the line is drawn per component rather than per route. One page can have parts that were rendered at build time, parts that were cached and will refresh on their own, and parts computed for this specific request — all in one response. So instead of asking about the page I would ask, for each component, whether its output is the same for everyone and whether it is stable for a while.
Mid
Your build fails with "Uncached data was accessed outside of <Suspense>". What happened, and what are your options?
Model answer — A component is doing request-time or uncached async work with no boundary above it, so the prerenderer has nowhere to put a fallback and refuses to build. There are three real options and the right one depends on the data. If it is the same for everyone and can be slightly stale, mark it use cache and it joins the static shell. If it genuinely varies per request — a cookie, search params — wrap it in <Suspense> so the fallback is prerendered and the content streams. If it turns out not to need request time at all, the fix is to stop reading the request. What I would not do is reach for whatever makes the message go away, because a boundary placed high in the tree satisfies the build while collapsing the whole page into one skeleton.
Senior
Component-level rendering boundaries — what does that design cost, and who pays?
Model answer — It moves complexity out of application code and into the hosting platform, which is a real trade rather than a free win. Serving static and dynamic in one response requires streaming. On-demand invalidation of any cache entry requires coordination across instances, or one instance expires something and the others keep serving it. Revalidation has to regenerate both the HTML and the RSC payload or a direct visit and a client navigation disagree, which is a very confusing bug report. And serving the prerendered shell at edge latency needs platform integration to store the shell and resume dynamic rendering. The useful distinction is functional versus performance fidelity: a plain Node or Docker deployment is fully supported and correct, and what you forgo without deeper integration is the last increment of speed plus some consistency during revalidation windows. I would want that settled before designing a page whose feel depends on an edge-served shell, because that is a deployment property, not a code property.
Company lens — how the same topic gets asked
micro1Practical: given a page with one personalised element, make the rest of it prerender. They are checking you reach for a boundary rather than a client fetch.
GoogleModel comparison: contrast build-time, route-level and component-level boundaries and defend where you would draw the line for a given product.
MetaJudgement per component: walk a page and say for each part whether it should be cached, streamed, or left alone, and why.
AmazonOperational: what infrastructure does this rendering model assume, and what degrades if a deployment target lacks it?
Hands-on

Two functions implementing the classification this chapter is built on. The first decides where a single component lands — the four outcomes, in the order the framework checks them, which is what makes the boundary rows come out right. The second walks a page and reports what the static shell actually contains, remembering that a streamed component still contributes its fallback to the shell. The cases that matter are the ones where a boundary is present but irrelevant, and the ones where it is absent and fatal.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Summary

  • Static and dynamic are properties of components, not routes. One response can carry build-time output, cached output and per-request output together.

  • There are three places to draw the line — build time, route level, component level — and each trades flexibility against infrastructure simplicity. Route-level is not wrong, just coarse.

  • Four outcomes, checked in order: use cache puts it in the shell, a runtime API needs a boundary, uncached async needs a boundary, and anything deterministic prerenders on its own.

  • A missing boundary is a build error, not a silent downgrade of the route to dynamic. That converts the previous model's most common invisible regression into a failing build.

  • <Suspense> does not make a component dynamic. It declares where a fallback goes if the component needs request time; the component's own APIs decide whether it does.

  • Deterministic includes synchronous I/O, so a readFileSync or a synchronous SQLite query runs at build time and freezes into the shell. Use connection() to opt into request time.

  • Non-determinism must be declared. crypto.randomUUID() can mean "unique per visitor" or "one shared value"; the framework refuses to guess.

  • Partial Prerendering is the default with Cache Components, not a per-route experiment you enable.

  • The cost is infrastructure: streaming, cache coordination across instances, consistency between the HTML and the RSC payload, and platform integration for an edge-served shell.

  • Functional fidelity is binary and broadly available; performance fidelity is a spectrum. A plain Node deployment is correct — what varies is the last increment of speed.

*This chapter described the model without touching the mechanism that makes most of it possible. The next chapter is that mechanism: the use cache directive — where it can be placed, how a cache key is built from arguments and closed-over values, why in-memory caching quietly fails on serverless, and the two variants I have not mentioned yet. It is Cache Components & 'use cache'.*

Editorial reflection

What I keep returning to about this model is that its most valuable property is not speed. It is that a set of decisions which used to be implicit are now stated. Under the route-level model the sentence "this page is dynamic" was a conclusion the framework reached about your code, usually because of one line you had forgotten writing, and you learned it from a graph. Under this one you say what you meant: cached, deferred, or neither, per component. The build fails if you have not said. That is more typing and it is more legible, and legibility is the thing that survives a team changing over. I am also struck by how much of the design is refusal. It refuses to guess what randomUUID meant. It refuses to make a route dynamic on your behalf. It refuses to build a page whose author has not accounted for request-time work. Every one of those refusals is a place where an earlier version was helpful and a later version decided that helpfulness had been costing people production incidents. There is a general lesson in that which outlives this framework: the accommodating default and the honest default are often different defaults, and a system that has been in the world long enough to watch its own conveniences fail tends to move towards the honest one.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.