#The caching model in Next.js 16
AddedCaching in the App Router used to be something that happened to you. In Next.js 13 and 14, fetch results were cached by default and a route was static unless it touched something request-specific, so a page could quietly serve week-old data and nobody knew which line of code made it static. Next.js 15 flipped the defaults the other way (fetch uncached, GET Route Handlers uncached, page segments no longer reused from the client router cache). Next.js 16 adds Cache Components, a new model you switch on with cacheComponents: true in next.config.ts. New apps made with create-next-app@16.4.3 have it on (together with partialPrefetching: true), and it is the model this chapter teaches first.
The rule of Cache Components fits on one line: nothing is cached unless you say 'use cache', and everything that can be computed ahead of time is. Next.js renders each route at build time and keeps everything that finishes without waiting for a request: plain JSX, imports, synchronous work, and the output of functions and components marked 'use cache'. That prerendered HTML is the route's static shell. Anything that has to wait for a request (cookies, headers, search params, an uncached database call) must sit inside a <Suspense> boundary. The shell then contains the boundary's fallback, and the real content streams into that hole when a request arrives. If runtime data is read outside any <Suspense>, the build fails and tells you to choose.
A good mental model is a printed form with blanks. The printer (the build) prints everything that is the same for every reader: the headings, the logo, the "this week's deals" box that you said may be reused for an hour. It leaves boxes blank where each reader's own details go, with "loading…" printed in them. When a reader arrives, the form is handed over instantly and a clerk fills in the blanks one by one as the answers come back. 'use cache' is how you tell the printer "you may print this, and reprint it every so often"; <Suspense> is how you mark a blank.
What is and isn't cached under Cache Components:
| Kind of work | Example | What Next.js does |
|---|---|---|
| Pure rendering, imports, synchronous computation | <h1>Store</h1>, JSON.parse(readFileSync(...)) | Runs at build time, goes into the static shell |
Work inside 'use cache' | a cached getDeals() or <Deals /> | Runs once, output stored and reused; in the shell if its lifetime isn't too short |
| Uncached async I/O | fetch(...), db.query(...) outside 'use cache' | Runs at request time; must be inside <Suspense> |
| Runtime APIs | cookies(), headers(), searchParams, unknown params | Runs at request time; must be inside <Suspense> |
| Non-deterministic values | new Date(), Math.random(), crypto.randomUUID() | You must choose: cache it ('use cache') or defer it (await connection() or await io() first) |
| Request memoization | the same fetch GET twice in one render, React.cache | Still deduplicated within one render, as before |
| Client router cache | revisiting a page you navigated to | Kept in the browser for the stale time of the cached content (at least 30 seconds) |
Here is a page with all three kinds of content: static, cached and runtime.
import { Suspense } from "react";
import { cookies } from "next/headers";
import { connection } from "next/server";
import { cacheLife } from "next/cache";
export default function Home() {
return (
<main>
<h1>Store</h1>
<Deals />
<Suspense fallback={<p>Loading your basket…</p>}>
<Basket />
</Suspense>
</main>
);
}
// Cached: runs once (at build time here), then every visitor gets the stored result.
async function Deals() {
"use cache";
cacheLife("hours");
return <p>Deals computed at {new Date().toISOString()}</p>;
}
// Runtime: reads a cookie, so it can only run when a real request arrives.
async function Basket() {
const basket = (await cookies()).get("basket")?.value ?? "empty";
await new Promise((resolve) => setTimeout(resolve, 1000)); // a slow cart service
await connection(); // the timestamp below must be this request's
return <p>Basket: {basket} (rendered at {new Date().toISOString()})</p>;
}- At
next build, Next.js rendersHome. The<h1>is plain JSX, so it is rendered straight into the shell. Nothing about it can differ between requests. <Deals />is marked'use cache'with thehoursprofile, so Next.js runs it during the build, stores its output (Deals computed at 2026-10-25T18:07:53.465Z, the build time) and puts that HTML in the shell too. Without'use cache'thenew Date()would fail the build (see below), because Next.js can't know whether you wanted one timestamp for everyone or one per request.<Basket />awaitscookies(). Cookies only exist on a real request, so the build can't finish this component. Because it sits inside<Suspense>, the build puts the fallback<p>Loading your basket…</p>into the shell in its place and records that this hole is still to be filled ("postponed").- At request time Next.js sends the shell immediately, then runs
Basketwith the request's cookies, waits out the one-second "cart service", and streams the result into the hole.await connection()is there because the timestamp is meant to be per request: it tells Next.js that what follows must only ever run for a real request. - Two requests a second apart get the same
Dealstimestamp and differentBaskettimestamps, and a request withbasket=chairgets its own basket. Every visitor shares the cached part; each visitor gets their own hole.
This is the route table next build printed for the app (the page above plus an about page that is just <h1>About us</h1>):
export default function About() {
return <h1>About us</h1>;
}Route (app) Revalidate Expire
┌ ◐ / 1h 1d
├ ○ /_not-found
└ ○ /about
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content/about is ○ Static: nothing in it waits for a request. / is ◐ Partial Prerender: a static shell plus a hole that streams. The Revalidate 1h and Expire 1d columns come from the hours profile of the cached <Deals /> inside the shell: the shell is regenerated in the background when that content is an hour old (see cacheLife).
What the running app sent (next start, then curl; HTML comments that React inserts between text parts removed for reading):
<p>Deals computed at 2026-10-25T18:07:53.465Z</p>
<p>Basket: empty (rendered at 2026-10-25T18:08:05.174Z)</p>
<p>Deals computed at 2026-10-25T18:07:53.465Z</p>
<p>Basket: empty (rendered at 2026-10-25T18:08:07.204Z)</p>
curl -b basket=chair http://localhost:4505/
<p>Basket: chair (rendered at 2026-10-25T18:08:09.231Z)</p>And when each part arrived, measured with a small Node script that reads the response body as it streams and notes the first chunk containing each piece of text:
"<h1>Store</h1>" +30 ms (chunk 1)
"Deals computed at" +30 ms (chunk 1)
"Loading your basket" +30 ms (chunk 1)
"Basket: " +1032 ms (chunk 11)
end of response +1035 ms (14 chunks)The heading, the cached deals and the basket fallback all arrive in the first chunk, 30 ms after the request. The basket arrives a second later in the same response. That is the static shell with a dynamic hole, observed.
What Next.js does when you don't choose
Cache Components refuses to guess. Two things fail the build: reading runtime data outside <Suspense>, and producing a non-deterministic value during prerendering. These were built in a scratch copy of the template (not one of the chapter's apps, since they don't build). A page that reads a cookie at the top level:
import { cookies } from "next/headers";
export default async function Page() {
const theme = (await cookies()).get("theme")?.value ?? "light";
return <h1>Theme: {theme}</h1>;
}Error: Route "/": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic- The build renders
Pageand reachesawait cookies(). There is no request, so the promise can't resolve during prerendering. - Next.js looks for the nearest
<Suspense>above that point to use as a hole. There isn't one: the only thing abovePageis the root layout, so the whole page would have to wait for the request, and the shell would be empty. - Under the previous model this silently made the whole route dynamic. Cache Components stops the build instead and lists the three choices: stream it (wrap in
<Suspense>), cache it ('use cache', for data that isn't per request), or accept a blocking route withexport const instant = false. - The fix you almost always want is the first: move the cookie read into a small component and wrap that in
<Suspense>, exactly likeBasketabove. See Runtime data.
A page that renders the current time with no decision about it:
export default function Page() {
return <h1>Now: {new Date().toISOString()}</h1>;
}Error: Route "/": Next.js encountered the unstable value `new Date()` while prerendering.
This value can change between renders, so it must be either prerendered or computed later.
Ways to fix this:
- [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
- [cache] Prerender and cache the value with `"use cache"`
- [client] Render the value on the client with `"use client"`
- [measure] If the value is for telemetry, use a timing API such as `performance.now()`
Learn more: https://nextjs.org/docs/messages/blocking-prerender-current-timenew Date()returns something different every time it runs. If the build baked it into the shell, every visitor would see the build time, which is right for "last published" and wrong for "now".- Next.js can't tell which you meant, so it stops and asks.
'use cache'says "one value, shared, reused until revalidated" (whatDealsdoes).await connection()(orawait io(), added in 16.3) inside a<Suspense>says "a fresh value per request" (whatBasketdoes). Math.random()andcrypto.randomUUID()get the same treatment.performance.now()doesn't, because it's meant for measuring, not rendering.
Why 15 and 16 changed the model
Interviewers often ask why Next.js keeps changing caching. The short history, as the upgrade guides describe it:
| Version | Default behaviour |
|---|---|
| 13–14 | fetch cached by default (force-cache); GET Route Handlers cached by default; client router reused dynamic pages for 30 seconds. Static unless something forced dynamic. |
| 15 | fetch not cached by default; GET Route Handlers not cached by default; client router no longer reuses page segments (staleTimes.dynamic 30 s → 0 s). Still route-level static vs dynamic. |
| 16 | Cache Components (cacheComponents: true): caching is explicit with 'use cache', cacheLife, cacheTag; Partial Prerendering is the default rendering for every route; revalidate, dynamic and fetchCache segment configs are replaced. Without the flag, 16 keeps the 15 model. |
The problem with 13–14 was implicit caching: data was cached because you didn't say otherwise, and a route went static or dynamic because of something three components away. 15 fixed the surprise by caching less. 16 fixes the granularity: the unit of caching is no longer the whole route but a function or component, so one page can be part shared-and-cached and part per-request without the cookie read in a header making the whole page dynamic.
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<p>Loading prices…</p>}>
<Prices />
</Suspense>
<Footer />
</>
);
}
// Header: plain JSX. Footer: 'use cache' + cacheLife('days').
// Prices: awaits fetch() with no 'use cache'.Show answer
<Header />, the text Loading prices… and <Footer /> are in the shell; the prices stream in at request time.
Headeris plain JSX: rendered at build time, in the shell.Pricesawaits an uncachedfetch, which under Cache Components is request-time work. The build can't finish it, so it uses the nearest<Suspense>fallback,Loading prices…, as the hole.Footeris cached with thedaysprofile. Its lifetime is long enough to be prerendered, so its output is in the shell, and the route'sRevalidatecolumn would show1d.- The route prints as
◐in the build output. IfPriceshad no<Suspense>around it, the build would fail with the "uncached or runtime data" error instead of making the page dynamic.
What the build stored for this route is worth seeing once. In .next/server/app/ the /about route has about.html and about.rsc (the HTML and the RSC payload). The / route has index.html (the shell) and index.meta, whose JSON includes the headers to send and a "postponed" field: React's serialised state of where it stopped, so a request can resume rendering the holes without re-rendering the shell. index.meta also lists the route's automatic cache tags, which revalidatePath uses:
"x-nextjs-stale-time": "300",
"x-nextjs-prerender": "1",
"x-next-cache-tags": "_N_T_/layout,_N_T_/page,_N_T_/,_N_T_/index"
},
"postponed": "1969:[1,{\"nextSegmentId\":1,\"rootFormatContext\":{...The response headers say the same thing from the outside. /about came back with x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000, a fully static file. / came back with x-nextjs-postponed: 1 and Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, because the HTML in this response includes one visitor's basket. The shell itself is still served from the prerender; it's the finished response that mustn't be cached by a shared cache.