Dynamic by default
The Next.js 16 Cache Components model, where every route is dynamic by default and caching is an explicit opt-in, and the request-time signals that mark a code path dynamic.
Here is a page.tsx for an invoices dashboard. It is an async Server Component, and its whole body is essentially one line:
export default async function InvoicesPage() { const invoices = await db.invoices.find(); // data layer: Unit 5
return <InvoiceTable invoices={invoices} />;}You already know how to write this. You wrote async Server Components that read data on the server in the chapter on the server/client boundary, and you wrapped slow reads in <Suspense> in the chapter on loading and streaming. This lesson answers a different question. It is not about how to write the component, since you can already do that. It is about when and where the component runs. Does this page render once at build time and get served from a file, does it render fresh on every request, or something in between? And whatever the answer turns out to be, what in the code decided it?
In Next.js 15 the honest answer was “it depends.” It depended on a set of implicit triggers you had to hold in your head, and the trigger that flipped the decision could be buried three components deep, where the page.tsx gave no hint of it. Next.js 16 replaces all of that with a single rule. By the end of this lesson you will be able to look at any route in your app and say what renders where. You will also see why dynamic by default is the better default: not a performance smell, but the correct shape for most of what you will build.
The old model: rendering inferred from your code
Section titled “The old model: rendering inferred from your code”You will meet the Next.js 13 to 15 model in older codebases, in blog posts, and in every migration guide written before 2026, so it is worth one pass to recognize it. After that you can drop it, because you will never write it.
The default was the inverse of what you are about to learn. A route was statically prerendered at build time unless something tripped it into dynamic rendering. The framework rendered your page once during next build and stored the resulting HTML and data payload. That stored result had a name, the Full Route Cache , and the framework served that same payload to every visitor until it was revalidated. This was fast, but only correct for pages that look the same for everyone.
The problem was how a route escaped that default. The triggers were implicit. Reading cookies() or headers(), doing an uncached fetch(), reading searchParams, or adding export const dynamic = 'force-dynamic' to the file: any one of them silently flipped the entire route from static to dynamic. You did not write “this route is dynamic” anywhere. The framework inferred it from your API usage.
The cost of that showed up when you tried to read a route. The trigger did not have to live in your page.tsx. A component three levels down, written by someone else, could reach for cookies() to read a session, and that single call, invisible from the top of the tree, flipped the whole route dynamic. So to answer “does this page render at build or at request time?” you could not read one file. You had to audit the entire subtree hunting for a dynamic API call, because the decision was real but invisible from the top.
The problem therefore had three properties: it was implicit, route-wide, and invisible. Keep those three in mind, because Next.js 16 addresses all of them.
The Next.js 16 default: dynamic, with caching as opt-in
Section titled “The Next.js 16 default: dynamic, with caching as opt-in”Here is the new rule, the central idea of the whole lesson.
With cacheComponents turned on, every route renders at request time by default. Take the InvoicesPage from the top: it is dynamic. It can read cookies(), await searchParams, and hit the database without flipping any flag, because there is no flag to flip. It is already dynamic. The thing you spent the old model trying not to trip is now the floor you start from.
You turn the model on with one line in next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true,};
export default nextConfig;The whole mental shift fits in one line: pre-16 you avoided tripping dynamic; post-16 you add caching. Dynamic is the default you start from, and caching is something you build up from there, piece by piece, on purpose. You opt a piece of the page back into build-time rendering with a directive called 'use cache', which sits in the same family as the 'use client' and 'use server' directives you already know. That is all you need to take from it right now: caching is opt-in, and the opt-in has a name. Its full anatomy, where you put it, what it caches, and the rules it imposes, is the lesson after next.
Two things are worth filing away for when you are reading real code. The first might surprise you: you have already been running under this model. The course starter you have used since the App Router chapter ships with cacheComponents: true from day one, so you have been writing dynamic-by-default routes this whole time, and this lesson is just the first time it has a name. The second is about older config. If you see experimental.dynamicIO or experimental.useCache in an older next.config.ts, those were the experimental ancestors of this flag. They are gone in Next.js 16, folded into the single top-level cacheComponents. You do not need to learn them, only recognize them as the old spelling.
One more reframe is worth making, because it runs against a common instinct. A route with no caching anywhere is fully dynamic, and that is the correct, common shape for an authenticated SaaS surface. Your invoices dashboard, the settings page, anything scoped to a logged-in user or an organization: these should be fully dynamic. They show different data to every user, so there is nothing meaningful to prerender. If you are carrying a reflex from the static-site era that says “dynamic equals slow equals bad,” set it aside. Here, dynamic is not a failure mode; it is the right answer most of the time. Caching is the targeted exception you reach for on the parts of the app that genuinely are the same for everyone, and we will get to exactly which parts those are.
The mental model: a route is a tree of dynamic and cached subtrees
Section titled “The mental model: a route is a tree of dynamic and cached subtrees”Here is the picture your mental model should settle into. You already reason about a route as a tree of components: that is the same tree from the server/client boundary chapter, the one you colored by environment (server vs. client). We are going to color the very same tree, but this time by render-time disposition: dynamic vs. cached.
Start with the simplest case. Every node in the tree is dynamic by default. That is the whole tree, one color, before you do anything.
Now you mark one node 'use cache'. That node and all of its children become a single cached entry. The subtree renders once, its output is stored, and on later matching requests it is served straight from cache instead of re-rendering. So a cached node is not a lone cached component; it is a cached region of the tree, root and descendants together.
That leads to the one hard rule of the model, the rule everything else follows from.
Dynamic content cannot live inside a cached subtree. If you mark a component 'use cache' and something inside it, or inside any of its children, awaits request data, the framework rejects it. It does not fail at runtime, and it does not fail silently: it fails the build with a clear error pointing at the offending read. The reason is almost obvious once you say it out loud. A cached result is computed once and reused, but request data is different on every request, so you cannot freeze the current user’s cookies into a value that gets served to everyone. A cached subtree has to be pure of request data.
So what do you do when a page needs both a cached part and a part that depends on the request? You do not try to make one component half-cached. You lift the dynamic work out to a sibling and give it its own boundary. Keep the cached subtree pure, and put the request-dependent work next to it rather than inside it.
At that sibling level, the page mixes freely. A cached header, next to a dynamic invoices table, next to a cached footer ad: three children of the same route, three different dispositions, one URL. The mixing is not the exception. It is how a real page is built.
Three children, three dispositions, one URL. The dynamic work sits beside the
cached siblings, never inside them — and its <Suspense> boundary is the seam.
A cached subtree includes its children, so request data cannot live inside it.
The framework fails the build — the fix is the
left tab: lift await cookies() out to a sibling with its own boundary.
The diagram above is the picture to carry out of this lesson. The left tab is the shape you will write constantly: pure cached siblings around a dynamic sibling, with the seam drawn as a boundary. The right tab is the mistake the build catches before you can ship it. Catching it at build time, loudly, rather than silently serving one user’s private data to everyone else, is exactly the legibility win this model is about.
Before we move on, work through the right-tab case yourself in the following question.
Sidebar is marked 'use cache', and it renders <Greeting /> as a child — which reads the request’s cookies. You run next build. What happens?
async function Greeting() { const store = await cookies(); return <p>Welcome back, {store.get('name')?.value}</p>;}
async function Sidebar() { 'use cache'; return ( <nav> <Greeting /> <NavLinks /> </nav> );}cookies() read, and you resolve it by moving Greeting out to a sibling of Sidebar with its own boundary.Sidebar serves from cache and Greeting quietly re-runs per request as an exception inside it./dashboard route quietly drops back to fully dynamic so nothing gets cached.Sidebar for everyone.'use cache' entry covers the node and its descendants, so there is no “just this child runs per request” — the cache freezes one computed result, but cookies() differs on every request, and the two are incompatible. So the framework refuses the combination at build time, with an error that names the offending read. It does not silently flip the route dynamic (that was the old Next.js 15 reflex this model replaces) and it does not bake one visitor’s cookies into a shared cache (the build error exists precisely to stop that). The fix is to keep Sidebar pure and lift Greeting out to a sibling with its own boundary.The Suspense boundary is the seam
Section titled “The Suspense boundary is the seam”You may have noticed the diagram drew the <Suspense> boundary as the line between the cached region and the dynamic sibling. That placement is deliberate. The Suspense boundary you learned in the chapter on loading and streaming is now doing a second job, and because it is the same primitive rather than a new one, the model costs you almost nothing to learn.
The second job is this. When a route has a cached shell and a dynamic sibling, the <Suspense> boundary around the dynamic part is what lets the cached shell ship to the browser immediately while the dynamic hole streams in once its data resolves. Without that boundary there is no seam, so the whole route has to wait for the slowest dynamic read before anything reaches the user, which throws away the entire benefit of caching the shell.
That reuse is the point, so it is worth staying with for a moment. This is the exact same <Suspense> from before, riding the exact same streaming transport, with the App Router flushing the shell first and resolved boundaries afterward over one HTTP response. The boundary that meant “show a fallback while this child loads” now also means “this is where the static part of the page ends and the dynamic part begins.” One primitive, two readings, and you already know the primitive.
The full rendering shape, a cached static shell flushed instantly with dynamic holes streaming into it, has a name: Partial Prerendering , the subject of the very next lesson. I name it here only so the term is not a stranger when you meet it, and so you can see why the next lesson builds directly on a boundary you already understand. For now, hold on to one thing: the seam between cached and dynamic is a Suspense boundary you already know how to draw.
Where dynamic comes from: the explicit signals
Section titled “Where dynamic comes from: the explicit signals”A code path is dynamic by default, but the framework still has to know which paths actually touch the request, so it knows what it cannot prerender. Where does that signal come from now? This is the part where “explicit beats implicit” becomes concrete, so it is worth being precise.
Under Cache Components, a code path becomes dynamic by awaiting a request-time API . There is a small, closed set of them, and this is the entire list:
id in /invoices/[id]
Every one is read with await — the exact syntax is a later
lesson; here, just know the set.
Notice what that closed list buys you. The dynamic signal is always an await on one of those APIs, sitting visibly in the source. There is no hidden third channel: no uncached fetch that silently flips the route, no deep child reaching for something the page cannot see. To find every dynamic dependency of a route, you search for these awaits. That is the legibility win made concrete: the question that used to require auditing an entire subtree is now answered by reading the code.
The same analysis works in the other direction. It enforces the purity rule from the tree section, now stated at the API level: a 'use cache' function that tries to await any of these fails the build. Cached output and request data are incompatible, so the framework refuses to compile the combination. This is the same rule as before, that request data cannot live in a cached subtree, except now you can see exactly which lines the framework checks.
Sort the following operations to check that the inventory has landed. Ask the same question of each one: does it read request data?
One question decides every chip: does this operation read request data? Drag each item into the bucket it belongs to, then press Check.
await cookies()await searchParamsawait headers()await db.invoices.find()Why each chip lands where it does
Forces dynamic, because each one awaits a request-time API, so it can only be answered once a real request arrives:
await cookies()reads the request’s cookies.await searchParamsreads the URL’s query string, which only exists per request.await headers()reads the request headers.
Can be cached, because none of these touch request data, so the same output is correct for every visitor:
- Rendering a static marketing header touches no data at all.
- Turning a Markdown string into HTML is a pure transformation, identical on every request.
await db.invoices.find()is the one that looks like a trap. A database read touches no request-time API, so by this lesson’s one rule it belongs in Can be cached: it becomes cacheable once you wrap it in'use cache'. Left alone in a dynamic-by-default page it simply runs fresh on every request, which is fine too. The deciding question is never “does it hit the network?” It is “does it read request data?”, and a bare query does not.
The escape hatch: connection()
Section titled “The escape hatch: connection()”The signals above cover almost everything, but there is one gap worth naming, because it carries “explicit beats implicit” through to its conclusion.
Some code must run at request time yet has no request-time API to give it away. Think of generating a random ID, reading Date.now() for a freshness stamp, or calling a third-party SDK that lazily reads process.env the moment it is invoked. None of those await cookies() or searchParams, so the static analyzer has no way to infer that the code has to be dynamic. Left alone, it might prerender that code at build time and bake in a build-time random number or timestamp, which is not what you meant.
For exactly these cases there is connection() , from next/server. Awaiting it is the explicit declaration that everything below this line is dynamic:
await connection();It is the manual version of the signal the framework usually detects on its own: you stepping in to say “trust me, this is per-request” when the code gives no other clue. Code that runs after it and produces per-request values typically lives inside a <Suspense> boundary so it can stream as a hole, the same shape as every other dynamic sibling. The worked usage is a later lesson; for now, file connection() as the named escape hatch. The point to keep is that the old model would have caught this kind of code by accident, through some incidental dynamic API, while the new model makes you declare it on purpose, where it is easy to see.
Reading what shipped: the build log
Section titled “Reading what shipped: the build log”There is one habit that closes the loop: rather than assuming what shipped where, read it off the build output.
Even though every route is dynamic by default, the build still runs a prerender pass. During next build, Next.js renders every 'use cache' boundary it can resolve without request data and stores the result. Then at request time, the cached parts serve from cache (or from a CDN) while the dynamic parts run fresh. That build-time pass is what makes the instant shell possible: the cached HTML already exists before the first user arrives. (The full mechanics are the next lesson; for now, just know the pass runs.)
Because the pass runs your cached components at build time, one consequence is worth stating plainly. A 'use cache' component that throws during that pass fails the whole build, not just one request. That is the behavior you want: a bug in cached, prerendered code surfaces at deploy time rather than in production.
The payoff is in the build log, which labels each route’s segments, marking which are prerendered (static), which are dynamic, and which stream. Reading the log confirms what actually shipped where, instead of leaving you to trust your mental model of it, and it is how you turn this abstract model into something you can verify on your own project. The build output looks roughly like the following. The exact glyphs vary by Next.js version, so read it for the disposition per route, not the symbols.
Route (app) Size First Load JS┌ ○ / 1.2 kB 98.3 kB├ ○ /pricing 0.9 kB 97.1 kB├ ◐ /dashboard 3.4 kB 102.5 kB└ ƒ /invoices 2.1 kB 100.4 kB
○ (Static) prerendered at build, served from cache◐ (Partial) cached shell ships instantly, dynamic holes stream inƒ (Dynamic) rendered fresh on every requestThe chapter ahead
Section titled “The chapter ahead”You now have the model: a dynamic floor, caching as an opt-in you build up from, and a Suspense boundary as the seam between the two. That is the whole frame, and the rest of this chapter fills it in. The next lesson turns the seam into the full rendering shape, Partial Prerendering. After that comes 'use cache' in complete detail, then how long cached things live and how you name them, then per-request memoization, then how you invalidate a cached value after a mutation, and finally the async request APIs in full, with the legacy config they replace. Every gap left open here is scheduled for one of those lessons, not forgotten.
External resources
Section titled “External resources”The canonical reference for cacheComponents, 'use cache', and the dynamic-by-default model.
An interactive lesson that walks the model with exercises and a decision framework for what to cache.
The before/after for the legacy route segment configs (dynamic, revalidate, fetchCache) you will meet in older code.