Dynamic by default
The Next.js 16 Cache Components model, where every route is dynamic by default, caching is an explicit opt-in, and request-time APIs are what 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: an async Server Component that reads data on the server, the kind you built around the server/client boundary and wrapped in <Suspense> for slow reads. This lesson asks a different question, not how to write the component but when and where it runs. Does the page render once at build time and serve from a file, render fresh on every request, or land somewhere in between? And whatever the answer, what in the code decided it?
In Next.js 15 the answer was “it depends,” on a set of implicit triggers you had to track, where the one that flipped the decision could sit three components deep and leave no trace in page.tsx. Next.js 16 replaces all of that with a single rule. By the end of this lesson you will look at any route and say what renders where, and see why dynamic by default is the right default: not a performance smell, but the correct shape for most of what you build.
The old model: static by default
Section titled “The old model: static by default”You will meet the Next.js 13 to 15 model in older codebases and migration guides, so it is worth one pass to recognize, even though 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 the page once during next build and served the stored result, the Full Route Cache , to every visitor until it was revalidated.
What tripped a route out of that default was implicit. Reading cookies() or headers(), an uncached fetch(), reading searchParams, or export const dynamic = 'force-dynamic': any one silently flipped the entire route dynamic. You never wrote “this route is dynamic”; the framework inferred it from your API usage.
And the trigger could be anywhere. A component three levels down could call cookies() to read a session, flipping the whole route dynamic from a spot invisible at the top of the tree. To answer “does this render at build or request time?” you had to audit the entire subtree for a dynamic API call.
So the old default had three problems: it was implicit, route-wide, and invisible. Next.js 16 addresses all three.
The Next.js 16 default: dynamic, caching opt-in
Section titled “The Next.js 16 default: dynamic, caching opt-in”With cacheComponents turned on, every route renders at request time by default. The InvoicesPage from the top is already dynamic: it can read cookies(), await searchParams, and hit the database without flipping any flag, because there is no flag to flip. The behavior 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. You opt a piece of the page back into build-time rendering with a directive called 'use cache', in the same family as the 'use client' and 'use server' directives you already know. The point to take now: caching is opt-in, and the opt-in has a name. Its full anatomy comes in the lesson after next.
One thing to file away: you have already been running under this model. The course starter you have used since the App Router chapter ships with cacheComponents: true, so your routes have been dynamic by default all along; this lesson is just the first time it has a name.
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 organization shows different data to every user, so there is nothing meaningful to prerender. Dynamic here 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.
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”You already reason about a route as a tree of components: the same tree from the server/client boundary chapter, colored there by environment (server vs. client). Color that tree again, this time by render-time disposition: dynamic vs. cached.
Every node is dynamic by default. Before you do anything, the whole tree is one color.
Mark one node 'use cache' and that node and all of its children become a single cached entry. The subtree renders once, its output is stored, and later matching requests are served straight from cache. A cached node is not a lone component; it is a cached region of the tree, root and descendants together.
That gives you the one hard rule everything else follows from.
Dynamic content cannot live inside a cached subtree. If a 'use cache' component, or any of its children, awaits request data, the framework fails the build with an error pointing at the offending read, rather than failing at runtime or silently. A cached result is computed once and reused, but request data differs on every request, so you cannot freeze the current user’s cookies into a value served to everyone. A cached subtree has to be pure of request data.
So when a page needs both a cached part and a request-dependent part, don’t make one component half-cached. Lift the dynamic work out to a sibling with 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, a dynamic invoices table, a cached footer ad: three children of one route, three dispositions, one URL. 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 left tab is the shape you will write constantly; the right tab is the mistake the build catches before you can ship it. Catching it loudly at build time, rather than serving one user’s private data to everyone else, is the legibility this model buys you.
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”The diagram drew the <Suspense> boundary as the line between the cached region and the dynamic sibling, and that is the same boundary you met in the chapter on loading and streaming, now doing a second job.
That boundary is what lets the cached shell ship to the browser immediately while the dynamic hole streams in once its data resolves: the App Router flushes the shell first, then the resolved boundary, over one HTTP response on the same streaming transport. Without it there is no seam, so the whole route waits for the slowest dynamic read before anything reaches the user, throwing away the benefit of caching the shell. The boundary that meant “show a fallback while this child loads” now also means “the static part of the page ends here and the dynamic part begins.”
This rendering shape, a cached static shell flushed instantly with dynamic holes streaming into it, has a name: Partial Prerendering , the subject of the next lesson, which builds directly on this 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 needs to know which paths actually touch the request, so it knows what it cannot prerender. That signal is explicit.
Under Cache Components, a code path becomes dynamic by awaiting a request-time API . The set is small and closed:
id in /invoices/[id]
Every one is read with await — the exact syntax is a later
lesson; here, just know the set.
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. The question that used to require auditing a whole subtree is now answered by reading the code.
The check runs the other way too. A 'use cache' function that awaits any of these fails the build: cached output and request data are incompatible, so the framework refuses to compile the combination. It is the purity rule from the tree section, now enforced on exactly the lines you can see.
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()”Some code must run at request time yet has no request-time API to give it away. Generating a random ID, reading Date.now() for a freshness stamp, calling a third-party SDK that lazily reads process.env when invoked: none of these await cookies() or searchParams, so the static analyzer cannot infer they are dynamic. Left alone, it may prerender them at build time and bake in a build-time random number or timestamp.
For these cases there is connection() , from next/server. Awaiting it declares that everything below this line is dynamic:
await connection();It is the manual form of the signal the framework usually detects on its own: you stepping in to say “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 streams as a hole like every other dynamic sibling. For now, file connection() as the named escape hatch.
Reading what shipped: the build log
Section titled “Reading what shipped: the build log”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, so the cached HTML exists before the first user arrives.
Because the pass runs your cached components at build time, a 'use cache' component that throws during it fails the whole build, not just one request. 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 as static, partial, or dynamic, confirming what actually shipped where instead of leaving you to trust your mental model. The glyphs are illustrative and vary by Next.js version, so read 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 requestExternal 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.