cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Caching before Cache Components

Read and fix Next.js apps on the older caching model: fetch cache options, unstable_cache, route segment config, the four caches, and the move to use cache.

By the end, you can
  • 01
    Read the older caching codeSay what fetch's cache and next options, unstable_cache and dynamic, revalidate and fetchCache do to a page and its data.
  • 02
    Know the four cachesTell request memoization, the Data Cache, the Full Route Cache and the Router Cache apart, and what Next.js 15 changed in them.
  • 03
    Move an app to Cache ComponentsKnow which older code keeps working, which stops the build, and what replaces each option.

The same tea shop, the older way

Cache Components is on in every new project since Next.js 16.4. Many apps were built before that, and still run the caching model Next.js had from version 13: caching set on each fetch, and options that set it for a whole route. Next.js 16.4.3 still runs that model when cacheComponents is off. Here is the tea shop's Teas page written that way, a real project built with next build and run with next start. Its next.config.ts sets no options, as create-next-app wrote it before 16.4:

The Teas page, without Cache ComponentsNext.js
app/teas/lib/
lib/supplier.ts
const api = process.env.SUPPLIER_API_URL; export type Tea = { id: string; name: string; price: number }; export async function getMenu(): Promise<Tea[]> {  const response = await fetch(`${api}/menu`, {    cache: "force-cache",    next: { revalidate: 3600, tags: ["menu"] },  });  return response.json();} export async function getStock(): Promise<{ boxes: number }> {  const response = await fetch(`${api}/stock`, { cache: "no-store" });  return response.json();}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ƒ /teas ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand

lib/supplier.ts, the file shown first, asks the tea supplier's API, whose address is in SUPPLIER_API_URL. getMenu, on lines 5 to 11, passes options to fetch: line 7, cache: "force-cache", keeps the answer, and line 8 keeps it for at most an hour and names it menu. getStock, on lines 13 to 16, says cache: "no-store" on line 14: never keep this answer. app/teas/page.tsx shows a heading, the menu, and the stock inside Suspense, laid out as in Cache Components.

The Terminal tab says ƒ /teas: server-rendered on demand, for every visit. There's no line saying Cache Components is enabled. The supplier in the recording is a stand-in on the same computer, and it kept a list of the requests it got: /menu once, during the build, and /stock twice, once for each visit. So the page was made for each visit, but the menu inside it came from a cache, filled during the build. That's the older model in one page: whether the page is kept, and whether its data is kept, are separate questions, answered by separate caches. And one no-store request makes the whole route dynamic; the same page with Cache Components is ◐, a static shell with only the stock made for each visit.

Two windows. A browser window holds the Router Cache, kept in the tab. An arrow leads to a window for next start, which holds three caches: request memoization, for one render; the Data Cache, kept across visits and deploys; and the Full Route Cache, the page, until it's made again.browserRouter Cachekept in the tabnext startRequest memoizationone renderData Cacheacross visits and deploysFull Route Cachethe page, until made again
Without Cache Components, four caches can answer before your code does work again. The browser keeps the pages it fetched in the Router Cache. The server shares a fetch within one render (request memoization), keeps fetch answers in the Data Cache, and keeps whole pages in the Full Route Cache.
Real-life analogyA tea shop that keeps things in four places

A clerk serving one customer checks the menu once, and remembers it until that customer leaves. Tea bought from the supplier sits on the stockroom shelf until its use-by date, for every customer. Some gift sets are wrapped before the doors open and handed over as they are. And each customer keeps their own list from the last visit, so they don't ask again for what they already know.

In real life: The clerk's note while serving one customer: "already checked the menu"
In Next.js: Request memoization: one fetch shared within one render
In real life: Boxes from the supplier on the stockroom shelf, with a use-by date
In Next.js: The Data Cache: fetch answers kept with force-cache and revalidate
In real life: Gift sets wrapped before the shop opens, handed over as they are
In Next.js: The Full Route Cache: whole pages made during the build
In real life: The customer's own list from their last visit, in their pocket
In Next.js: The Router Cache: pages kept in the visitor's browser tab
The main idea

Before Cache Components, you set caching in two places: on each request for data, and for each route. fetch options and unstable_cache decide whether data is kept. Route segment config, and what the page reads, decide whether the whole page is made during the build and kept, or made for each request. Four caches hold the results.

fetch's cache option, next.revalidate and next.tags

On the server, Next.js extends fetch. In a browser, cache says how a request uses the browser's own HTTP cache. In Next.js's server code, it says how the request uses the server's cache of fetch answers, the Data Cache. The docs say a cached request matches on its URL, method, headers and body, and only answers with a 200 status are stored. Next.js also reads one more option, next, with a lifetime and names.

Each button below is one version of the Teas page with a single fetch of the menu, on line 4, or line 7 in the versions that read a cookie on line 6. Each was visited twice, with the cookie favorite=mint. Before you click, guess for each: does the build print ○ or ƒ, and how often does the supplier get /menu?

Playground: what does each fetch option do?
The Teas page, with this version of its fetchNext.js
app/teas/
app/teas/page.tsx
const api = process.env.SUPPLIER_API_URL; export default async function TeasPage() {  const response = await fetch(`${api}/menu`);  const teas: { id: string; name: string; price: number }[] = await response.json();  return (    <main>      <h1>Our teas</h1>      <ul>        {teas.map((tea) => (          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ○ /teas ○  (Static)  prerendered as static content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/_global-error.html  8.9 kB.next/server/app/_global-error.meta  676 bytes.next/server/app/_global-error.rsc  4.5 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc  4.2 kB.next/server/app/_global-error.segments/_full.segment.rsc  4.5 kB.next/server/app/_global-error.segments/_tree.segment.rsc  110 bytes.next/server/app/_not-found.html  7.3 kB.next/server/app/_not-found.meta  766 bytes.next/server/app/_not-found.rsc  4.5 kB.next/server/app/_not-found.segments/_full.segment.rsc  4.5 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc  4.0 kB.next/server/app/_not-found.segments/_tree.segment.rsc  164 bytes.next/server/app/index.html  4.9 kB.next/server/app/index.meta  595 bytes.next/server/app/index.rsc  3.1 kB.next/server/app/index.segments/__PAGE__.segment.rsc  2.8 kB.next/server/app/index.segments/_full.segment.rsc  3.1 kB.next/server/app/index.segments/_tree.segment.rsc  110 bytes.next/server/app/teas.html  5.6 kB.next/server/app/teas.meta  713 bytes.next/server/app/teas.rsc  3.6 kB.next/server/app/teas.segments/_full.segment.rsc  3.6 kB.next/server/app/teas.segments/_tree.segment.rsc  157 bytes.next/server/app/teas.segments/teas/__PAGE__.segment.rsc  3.0 kB
Try it yourself
No option

The build prints ○ /teas. The supplier was asked for /menu once, during the build, and never during the two visits: the page itself was kept, with the menu in it. The Network tab says the response was teas.html, as the build wrote it.

Each version was built and run with Next.js 16.4.3, in a project without Cache Components, and visited twice with the cookie favorite=mint. The Terminal shows the route table; the Network tab shows both visits. Reset goes back to the version with no option.

The supplier's list of requests is the clearest way to read the results. Each row of the picture is one version, and each card says who gave the page its menu:

A grid. Columns: during the build, visit 1, visit 2. With no option: the supplier during the build, then the kept page twice. With no-store: not run during the build, then the supplier twice. With force-cache: the supplier during the build, then the kept page twice. With no option and a cookie: not run, then the supplier twice. With force-cache and a cookie: not run, the supplier, then the Data Cache.during the buildvisit 1visit 2no optionsupplierkept pagekept pagecache: "no-store"not runsuppliersuppliercache: "force-cache"supplierkept pagekept pageno option + cookienot runsuppliersupplierforce-cache + cookienot runsupplierData Cache
Who gave the Teas page its menu, in each recorded version. Shaded cards are requests that reached the supplier. A page made during the build was kept whole; once a cookie made the page dynamic, only force-cache kept the menu itself.
  • No option is not "no cache". The page read nothing from the request, so the build made it, fetch and all, and kept the whole page: ○. The menu came from the page, not from the Data Cache. The Network tab shows it: the response was teas.html, as the build wrote it.
  • With a cookie, the page is made for each visit, and an option-less fetch runs each time. Only force-cache kept the menu: fetched on the first visit, and taken from the Data Cache on the second.
  • no-store runs the fetch for every request, and so makes the page dynamic even without a cookie.

Each row is one option: what the docs say it does, and what the recordings showed.

fetch options without Cache Components
OptionWhat it doesIn the recordings
No optionFetched during next build if the page can be made then; for each request if the page reads the request.○, menu once during the build. With a cookie: ƒ, menu on every visit.
cache: "no-store"Fetched for every request, and the page can't be made ahead.ƒ, menu on every visit.
cache: "force-cache"Kept in the Data Cache, and used again by later requests, until it's revalidated.○, menu once. With a cookie: ƒ, menu once, on the first visit.
next: { revalidate: 60 }Kept for at most 60 seconds; the route's lifetime can't be longer.○ /teas 1m 1y, menu once during the build.
next: { tags: ["menu"] }Names the kept answer, so revalidateTag("menu", "max") can mark it out of date later.Used by the older shop's menu, with force-cache and revalidate: 3600.

A few rules from the docs:

  • The lowest lifetime wins. A fetch with a shorter revalidate than its route's makes the whole route revalidate that often. Two fetches of the same URL with different lifetimes use the lower one.
  • Conflicting options are ignored. { revalidate: 3600, cache: "no-store" } sets neither, and next dev prints a warning.
  • Tags name answers. next: { tags: ["menu"] } lets revalidateTag("menu", "max") mark them out of date after an edit; Revalidating cached data covers it.

unstable_cache for work that isn't fetch

Next.js wires fetch into the Data Cache for you, through its options. A database query, an ORM, or a file read doesn't go through fetch, so it needs another way in: unstable_cache from next/cache, which wraps an async function and keeps what it returns in the same Data Cache. Here the shop's teas are in a file, data/teas.json, standing in for a database, and the Teas page also reads the favorite cookie, so the page is made for each visit:

lib/teas.ts, with unstable_cacheTypeScript
import { readFile } from "node:fs/promises";
import { unstable_cache } from "next/cache";
import type { Tea } from "./tea";

export const getTeas = unstable_cache(
  async (): Promise<Tea[]> => {
    console.log("Reading data/teas.json");
    return JSON.parse(await readFile("data/teas.json", "utf8"));
  },
  ["teas"],
  { revalidate: 3600, tags: ["teas"] },
);

Line 5 wraps the function on lines 6 to 9. Line 7 prints a line each time the function really runs, so the recording can count the runs. Line 10, ["teas"], is the key parts: extra values for the cache key. The docs say the function's arguments and its code already make the key, so key parts are only needed for outside values the function uses without taking them as arguments. Line 11 sets a lifetime of an hour and the tag teas. Compare it with the same function unwrapped:

lib/teas.ts, without unstable_cacheTypeScript
import { readFile } from "node:fs/promises";
import type { Tea } from "./tea";

export async function getTeas(): Promise<Tea[]> {
  console.log("Reading data/teas.json");
  return JSON.parse(await readFile("data/teas.json", "utf8"));
}
Playground: how often does getTeas run?
The Teas page, with this version of lib/teas.tsNext.js
app/teas/data/lib/
lib/teas.ts
import { readFile } from "node:fs/promises";import type { Tea } from "./tea"; export async function getTeas(): Promise<Tea[]> {  console.log("Reading data/teas.json");  return JSON.parse(await readFile("data/teas.json", "utf8"));}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ƒ /teas ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand $ next start▲ Next.js 16.4.3- Local:         http://127.0.0.1:3000- Network:       http://127.0.0.1:3000✓ ReadyReading data/teas.jsonReading data/teas.jsonReading data/teas.json
Try it yourself
Without unstable_cache

Three visits, three times Reading data/teas.json in what next start printed: the page is rendered for each visit, and so is getTeas.

Both versions were built and run with Next.js 16.4.3, without Cache Components, and visited three times. The Terminal shows what next build and then next start printed. Reset goes back to the version without unstable_cache.

Without unstable_cache, three visits printed Reading data/teas.json three times. With it, once: the first visit ran the function, and the next two got its copy, while each visitor still saw their own favorite. Two rules from the docs:

  • No request data inside. The wrapped function can't call cookies() or headers(). Read them outside, and pass the values in as arguments, so they become part of the key.
  • It's been replaced. The docs say "use cache" replaced unstable_cache in Next.js 16, and recommend moving to it with Cache Components.

One more tool shows up next to it in older code: React's cache, which shares a function's result within one render, like request memoization does for fetch. It keeps nothing between requests.

Route segment config: dynamic, revalidate and fetchCache

Route segment config is an option a page, layout or Route Handler sets by exporting a constant, read by Next.js for that part of the address. In the older model, three of them set caching for a whole route:

  • dynamic: "auto" (the default), "force-dynamic" to make the route for every request, "force-static" to make it during the build no matter what, or "error" to make it during the build and fail if anything stops that.
  • revalidate: false (the default, kept until something revalidates it), 0 to make the route for every request, or a number of seconds: the route's lifetime, which makes it ISR (Incremental Static Regeneration).
  • fetchCache: "auto" by default, or one of six values that change the cache option of every fetch in the route: "default-cache", "only-cache" and "force-cache" lean toward keeping answers, "default-no-store", "only-no-store" and "force-no-store" toward not keeping them. default- changes only fetches with no option, only- also fails on a fetch that asks for the other, and force- overrides every fetch. The docs call it an advanced option, only for overriding the default.

Each button is the Teas page with one option. The first four read the cookie; the last two don't, so you can see an option change a page that would otherwise be ○. Before you click, guess what force-static does with the cookie.

Playground: what does each route option do?
The Teas page, with this route segment configNext.js
app/teas/
app/teas/page.tsx
import { cookies } from "next/headers"; const api = process.env.SUPPLIER_API_URL; export default async function TeasPage() {  const favorite = (await cookies()).get("favorite")?.value;  const response = await fetch(`${api}/menu`);  const teas: { id: string; name: string; price: number }[] = await response.json();  return (    <main>      <h1>Our teas</h1>      <p>{favorite ? `Your favorite: ${favorite}` : "Pick a favorite tea"}</p>      <ul>        {teas.map((tea) => (          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>        ))}      </ul>    </main>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ƒ /teas ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand
Try it yourself
No option

The page reads a cookie on line 6, so it's ƒ /teas, and the plain fetch on line 7 runs for each visit: /menu twice. Both visits say Your favorite: mint.

Each version was built with Next.js 16.4.3, without Cache Components, and visited twice with the cookie favorite=mint. The first four pages read that cookie; the last two don't. Reset goes back to the version with no option.

force-static built ○ /teas, and both visits said Pick a favorite tea, though they sent favorite=mint: the docs say it makes cookies(), headers() and useSearchParams() return empty values. "error" stopped the build at the cookie. fetchCache = "default-cache" left the page dynamic but kept the menu. Two more rules from the docs:

  • The lowest revalidate wins across a route's layouts and page, so a page can't be kept longer than its layout allows.
  • The value must be a plain number: revalidate = 600 works, revalidate = 60 * 10 doesn't, because Next.js reads it without running your code.

Those three aren't the only exports. Each row is one route segment option an older app may have, and what Next.js 16.4.3 does with it once Cache Components is on, from its source (react_server_components.rs, lines 935 to 970) and the recordings:

Route segment options, and Cache Components
OptionWhat it doesWith Cache Components on
dynamicForces the route to be made for each request, or during the build.Stops the build.
dynamicParamsWith false, answers 404 for params generateStaticParams didn't list.Stops the build. Call notFound() instead.
revalidateSets the route's lifetime, or 0 to make it for each request.Stops the build. Use cacheLife.
fetchCacheChanges the default cache option of the route's fetches.Stops the build. Use "use cache".
runtimePicks "nodejs", the default, or the deprecated "edge".Stops the build, even with "nodejs". Remove it.
maxDurationTells the hosting platform how long server work may run.Still works: the recorded shop built ◐ /teas.
preferredRegionAsks the platform for a region. Deprecated.Not one of the options the build rejects, but the docs say to remove it.
app/teas/page.tsx, with dynamic = "force-static" (the top of the file)TSX
import { cookies } from "next/headers";

const api = process.env.SUPPLIER_API_URL;

export const dynamic = "force-static";

export default async function TeasPage() {
  const favorite = (await cookies()).get("favorite")?.value;

Line 5 is the option, and line 8 reads the cookie. With force-static, line 8 gets nothing at build time, and the page made then is the one every visitor gets.

The four caches

Next.js's docs for version 15 describe the older model as four caches, and the name stuck: you'll meet "the four caches" in many articles and code reviews. Each row is one of them, from those docs:

The four caches of the older model
CacheWhat it keepsWhereHow longHow to skip it
Request memoizationWhat a GET fetch returned, for the same URL and options.Server, one renderUntil that render ends.Nothing to skip: it's React's, and lasts one render.
Data CacheWhat a cached fetch or unstable_cache returned.Server, on diskAcross requests and deployments, until it's revalidated.cache: "no-store", or no option on a dynamic page.
Full Route CacheA whole page: its HTML and RSC payload.ServerUntil the route is revalidated, or the next deployment.Read the request, dynamic = "force-dynamic", or an uncached fetch.
Router CacheThe RSC payloads of pages the visitor opened or prefetched.Browser, in memoryThe tab's life, and staleTimes for each page segment.router.refresh(), or a Server Action that revalidates or sets a cookie.

Request memoization

Two components on one page often need the same data. Here, MenuCount and MenuList both call getMenu(), which fetches with no-store:

Two components, one fetch per renderNext.js
app/teas/
app/teas/page.tsx
const api = process.env.SUPPLIER_API_URL; type Tea = { id: string; name: string; price: number }; async function getMenu(): Promise<Tea[]> {  const response = await fetch(`${api}/menu`, { cache: "no-store" });  return response.json();} async function MenuCount() {  const teas = await getMenu();  return <p>{teas.length} teas today</p>;} async function MenuList() {  const teas = await getMenu();  return (    <ul>      {teas.map((tea) => (        <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>      ))}    </ul>  );} export default function TeasPage() {  return (    <main>      <h1>Our teas</h1>      <MenuCount />      <MenuList />    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
Home
Our teas

2 teas today

  • Green tea: $4
  • Mint tea: $3

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Line 6 is the one fetch; lines 11 and 16 call it from two components. The page was visited twice, and the supplier got /menu twice, not four times. Within one render, the second call to the same GET got the first call's answer. The docs say this is React's doing, not Next.js's, that it only covers GET, and that it doesn't apply in Route Handlers, which aren't part of a React render. It lasts one render, so no-store still fetched again on the next visit.

The Data Cache and the Full Route Cache

The Data Cache is what force-cache, next.revalidate and unstable_cache fill. The docs say it lasts across requests and across deployments. In Next.js 16.4.3, the default cache writes it to files in .next/cache/fetch-cache, outside the build's own output, so it can outlive a new build; that folder is an internal of this version. The Full Route Cache is the pages the build made, like the teas.html the No option version was served from. A new deployment clears it. The two are linked: the docs say revalidating data in the Data Cache also clears the kept pages made from it, so they're made again, and a page made for each request can still read from the Data Cache, as the force-cache, and a cookie version did.

The Router Cache

The fourth cache is in the browser. After a client-side navigation, the router keeps the page's RSC payload, React's description of what the Server Components rendered, so going back doesn't ask the server again (Linking and navigating showed the requests). How long it keeps a page changed in Next.js 15, in the next part.

What Next.js 15 changed

The older model started out caching as much as it could. Next.js 15, released on October 21, 2024, changed three defaults. Its release post says fetch requests, GET Route Handlers and client navigations are no longer cached by default. Each row is one of them, from the post and the version 15 upgrade guide:

Three defaults Next.js 15 changed
WhatBefore 15From 15To get the older way back
A fetch with no cache optionKept in the Data Cache, as if force-cache.Not kept: fetched again for each request a dynamic page renders.cache: "force-cache", or fetchCache = "default-cache" in a layout.
A GET Route HandlerMade during the build and kept, unless it read the request.Run for each call: ƒ.export const dynamic = "force-static".
A dynamic page in the Router CacheReused for 30 seconds after a visit.Fetched again on every navigation: stale time 0.experimental.staleTimes.dynamic.

The first row is in the fetch playground above: with a cookie and no option, the menu was fetched on every visit. The second is a Route Handler that answers GET /api/menu with the menu. Before you click, guess which version asks the supplier on each call.

Playground: is this GET Route Handler cached?
The shop's menu as JSON, app/api/menu/route.tsNext.js
app/api/menu/
app/api/menu/route.ts
const api = process.env.SUPPLIER_API_URL; export async function GET() {  const response = await fetch(`${api}/menu`);  return Response.json(await response.json());}
Recorded from Next.js 16.4.3

GET /api/menu200

Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type application/json

The body that arrived, as it arrived

[{"id":"green","name":"Green tea","price":4},{"id":"mint","name":"Mint tea","price":3}]
Try it yourself
No option

ƒ /api/menu: the handler runs for each call, and the supplier got /menu twice. Before Next.js 15, a GET like this one was cached by default.

Both versions were built and run with Next.js 16.4.3, without Cache Components, and called twice. Reset goes back to the version with no option.

With no option, ƒ /api/menu: each call ran the handler, and the supplier got /menu twice. With export const dynamic = "force-static", ○ /api/menu: made during the build, and both calls got the stored answer with x-nextjs-cache: HIT. The release post adds that special Route Handlers like sitemap.ts and opengraph-image.tsx stay static by default.

The third row needs a browser. Each version below was clicked through in real Chrome: open Home, click Teas, click Home, and click Teas again. The Teas page fetches with no-store, so it's dynamic. The second version adds this to next.config.ts:

next.config.ts, with staleTimesTypeScript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    staleTimes: { dynamic: 3600 },
  },
};

export default nextConfig;

Lines 4 to 6 set experimental.staleTimes.dynamic: how long, in seconds, the router may reuse a dynamic page it already has. Before Next.js 15 the default was 30 seconds. The recording uses 3,600, an hour, on purpose: four clicks take a few seconds, so the second click on Teas can't land after the window, however slow the computer. Open the Steps tab on the last click in each version:

Playground: does a second click fetch the page again?
The shop, with this next.config.tsNext.js
app/teas/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  /* config options here */}; export default nextConfig;
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teasTea shop
Home Teas
Our teas
  • Green tea: $4
  • Mint tea: $3
Focus
the link “Teas”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. GET/teas200text/x-component
    rsc
    1
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C0%5D%7D%2Cnull%2C%22refetch%22%2C0%5D%7D%2Cnull%2Cnull%2C16%5D
    next-url
    /
Try it yourself
Default staleTimes

Open the last step, Click Teas again: the router asked the server for /teas again, an RSC request, even though it showed /teas a moment before. A page segment's stale time is 0 seconds by default.

Both versions were built with Next.js 16.4.3, without Cache Components, and clicked through in real Chrome: open Home, click Teas, click Home, click Teas again. The Steps tab opens on the last click. Reset goes back to the default.

By default, the last click sent one request: /teas again, with the router's rsc header. With staleTimes.dynamic: 3600, it sent none. The staleTimes docs list the change: dynamic went from 30 seconds to 0 in Next.js 15, and static, for pages made ahead or fully prefetched, stays at 5 minutes. In Next.js 16.4.3's code, the build turns the option into a number the router reads, 0 when it isn't set (define-env.ts, lines 235 to 244), and the router adds it to the time a page arrived to get the moment it goes stale (bfcache.ts, lines 17 to 24). The release post says what didn't change: shared layouts aren't fetched again, Back and Forward still restore from the cache, and a loading.js is kept for 5 minutes. The option is still under experimental, as its name in the config says.

Moving to Cache Components

Cache Components needs Next.js 16, so an app on 15 or older upgrades first. Then the migration guide's steps are short: turn on cacheComponents, replace each route segment config the build rejects, then follow what the build and next dev report, caching data with "use cache" or streaming it in Suspense. Here is the older shop, moved one step at a time. Before you click, guess which step stops the build.

Playground: moving the shop to Cache Components
The tea shop, at this stepNext.js
app/teas/lib/
lib/supplier.ts
const api = process.env.SUPPLIER_API_URL; export type Tea = { id: string; name: string; price: number }; export async function getMenu(): Promise<Tea[]> {  const response = await fetch(`${api}/menu`, {    cache: "force-cache",    next: { revalidate: 3600, tags: ["menu"] },  });  return response.json();} export async function getStock(): Promise<{ boxes: number }> {  const response = await fetch(`${api}/stock`, { cache: "no-store" });  return response.json();}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ ƒ /teas ○  (Static)   prerendered as static contentƒ  (Dynamic)  server-rendered on demand
Try it yourself
1. The older shop

No Cache Components. force-cache keeps the menu in the Data Cache, and no-store makes the whole route dynamic: ƒ /teas. The supplier got /menu once, during the build, and /stock on each visit.

Each step was built and run with Next.js 16.4.3. Steps 2 to 4 use create-next-app's next.config.ts, with Cache Components on; step 1 sets no options. Reset goes back to step 1.

Step by step:

  1. The older shop is ƒ /teas, as in the overview.
  2. Cache Components on, nothing else changed. It builds: ◐ /teas 1h 1y. The migration guide says your existing fetch and unstable_cache caching keeps working, and the recording agrees: the force-cache menu was fetched once, during the build, and is in the static shell, while the no-store stock streams in.
  3. A route option stops it. Many apps upgraded from Next.js 14 added export const fetchCache = "default-cache" to the root layout, as the version 15 upgrade guide suggests for keeping the older default. With Cache Components on, the build stopped at ./app/layout.tsx:4:14: Route segment config "fetchCache" is not compatible with nextConfig.cacheComponents. Please remove it. Cache Components recorded the same for dynamic and revalidate, and export const runtime = "nodejs" stopped this shop's build too, though nodejs is the default. export const maxDuration = 5 built.
  4. Moved. getMenu gets "use cache", cacheLife("hours") and cacheTag("menu"), and both fetches lose their options. ◐ /teas 1h 1d: the Expire time is now the hours profile's day, instead of the older default of a year.
lib/supplier.ts, moved to Cache ComponentsTypeScript
import { cacheLife, cacheTag } from "next/cache";

const api = process.env.SUPPLIER_API_URL;

export type Tea = { id: string; name: string; price: number };

export async function getMenu(): Promise<Tea[]> {
  "use cache";
  cacheLife("hours");
  cacheTag("menu");
  const response = await fetch(`${api}/menu`);
  return response.json();
}

export async function getStock(): Promise<{ boxes: number }> {
  const response = await fetch(`${api}/stock`);
  return response.json();
}

Line 8 caches what getMenu returns, line 9 gives it the hours profile, and line 10 names it menu, so revalidateTag("menu", "max") still works. Line 16 fetches the stock with no option: with Cache Components, it's fresh for each request, and the Suspense on the page streams it. Each option maps to a replacement:

Six arrows from older code to what replaces it. force-cache and revalidate become use cache with cacheLife. next tags becomes cacheTag. unstable_cache becomes a use cache function. export const revalidate becomes cacheLife on cached code. dynamic force-dynamic: remove it. fetchCache becomes use cache where needed.Older codeWith Cache Componentsforce-cache, revalidate"use cache" + cacheLifenext: { tags }cacheTag("menu")unstable_cache(…)"use cache" functionexport const revalidatecacheLife on cached codedynamic = "force-dynamic"remove itfetchCache = "…""use cache" where needed
Each older option on the left, and what does its job with Cache Components on the right. The options that set caching for a whole route stop the build; fetch options and unstable_cache keep working until you move them.
Older code, and what to write with Cache Components
Older codeWith Cache Components onWhat to write instead
fetch with force-cache, next.revalidate, next.tagsKeeps working: the older shop built, with its menu in the shell.A function with "use cache", cacheLife and cacheTag.
unstable_cache(fn, keys, { revalidate, tags })Keeps working: the recorded page built as ○ /teas 1h 1y.A function with "use cache"; its arguments are the key.
export const dynamic = "force-dynamic"Stops the build.Remove it. Uncached work already runs for each request.
export const dynamic = "force-static"Stops the build."use cache" with a cacheLife; for a strict guarantee, ensureStatic.
export const revalidate = 3600Stops the build.cacheLife("hours") on the cached work.
export const fetchCache = "default-cache"Stops the build, even in the root layout."use cache" around the fetches to keep.
export const runtime = "nodejs"Stops the build, though it's the default.Remove it.
unstable_noStore()Not needed.Remove it: uncached is the default.

Three more things from the migration guide:

  • Where the cache lives changes. The Data Cache and unstable_cache keep entries across deployments. "use cache" keeps them in memory by default, for one deployment; "use cache: remote" or a cache handler stores them elsewhere (Private and remote caches).
  • One route at a time. export const instant = false on a segment lets it block while you work on others, and a codemod, cache-components-instant-false, adds it everywhere. It doesn't clear errors about values like new Date().
  • Revalidation. revalidateTag now takes a profile, like revalidateTag("menu", "max"), and updateTag is there for a Server Action whose user must see the change at once. revalidatePath is unchanged.

Reading an older codebase

Before turning Cache Components on in an older app, search it for the options above: cache:, next: {, unstable_cache, unstable_noStore, and export const lines named dynamic, revalidate, fetchCache, runtime and dynamicParams. Each one tells you what the page relied on. Sort these lines by what happens to them when Cache Components is turned on:

Keeps working, stops the build, or not needed?
  • fetch(url, { cache: "force-cache" })
  • unstable_cache(getTeas, ["teas"])
  • export const fetchCache = "default-cache" in the root layout
  • export const revalidate = 3600
  • export const dynamicParams = false
  • unstable_noStore() at the top of a page
Try it yourself
0 of 6 correct

Sort each line of older code by what happens when the app turns on Cache Components.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Fix the ones that stop the build first; they're the only ones you must change. Then move fetch options and unstable_cache to "use cache" when you're ready, page by page, checking the route table after each change.

Common mix-ups

  • "A fetch with no option is never cached." No. On a page that reads nothing from the request, the build made the page with the fetch in it, and both visits got that page. The answer wasn't in the Data Cache, but the page was kept.
  • "A dynamic page can't use cached data." No. With a cookie, the page was ƒ, and force-cache still kept the menu.
  • "force-static caches the page, and still reads the cookie." No. It makes cookies() empty: every visitor got Pick a favorite tea.
  • "Two components fetching the same URL make two requests." No. Within one render, the second GET gets the first one's answer.
  • "Turning on Cache Components breaks every fetch option." No. The older shop built with its force-cache menu in the shell. What stops the build is five route options: dynamic, dynamicParams, revalidate, fetchCache and runtime.
  • ""use cache" stores data the way the Data Cache did." No. By default it keeps entries in memory, for one deployment; the Data Cache lasts across deployments.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upCount the requests with no-store

In a project without Cache Components, this page was built once and visited twice. How many times did the supplier get /menu?

Starter codeTSX
const api = process.env.SUPPLIER_API_URL;

export default async function TeasPage() {
  const response = await fetch(`${api}/menu`, { cache: "no-store" });
  const teas: { id: string; name: string; price: number }[] = await response.json();
  return (
    <main>
      <h1>Our teas</h1>
      <ul>
        {teas.map((tea) => (
          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
        ))}
      </ul>
    </main>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upA cookie on a forced static page

    A visitor with the cookie favorite=mint opens this page. What does the paragraph under the heading say?

    Starter codeTSX
    import { cookies } from "next/headers";
    
    const api = process.env.SUPPLIER_API_URL;
    
    export const dynamic = "force-static";
    
    export default async function TeasPage() {
      const favorite = (await cookies()).get("favorite")?.value;
      const response = await fetch(`${api}/menu`);
      const teas: { id: string; name: string; price: number }[] = await response.json();
      return (
        <main>
          <h1>Our teas</h1>
          <p>{favorite ? `Your favorite: ${favorite}` : "Pick a favorite tea"}</p>
          <ul>
            {teas.map((tea) => (
              <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
            ))}
          </ul>
        </main>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeKeep the menu on a dynamic page

      Without Cache Components, this page fetches the menu on every visit. Change one line so the menu is kept. How many times does the supplier get /menu over two visits then?

      Starter codeTSX
      import { cookies } from "next/headers";
      
      const api = process.env.SUPPLIER_API_URL;
      
      export default async function TeasPage() {
        const favorite = (await cookies()).get("favorite")?.value;
        const response = await fetch(`${api}/menu`);
        const teas: { id: string; name: string; price: number }[] = await response.json();
        return (
          <main>
            <h1>Our teas</h1>
            <p>{favorite ? `Your favorite: ${favorite}` : "Pick a favorite tea"}</p>
            <ul>
              {teas.map((tea) => (
                <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
              ))}
            </ul>
          </main>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep a function that isn't fetch

        The Teas page reads a cookie and calls getTeas(). Wrap getTeas so its result is kept for an hour. Over three visits, how many times is Reading data/teas.json printed?

        Starter codeTypeScript
        import { readFile } from "node:fs/promises";
        import type { Tea } from "./tea";
        
        export async function getTeas(): Promise<Tea[]> {
          console.log("Reading data/teas.json");
          return JSON.parse(await readFile("data/teas.json", "utf8"));
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeKeep dynamic pages in the router

          The shop wants the router to reuse a dynamic page it already has for an hour. Set that in this next.config.ts. In the recorded clicks (Home, Teas, Home, Teas), how many requests does the last click make then?

          Starter codeTypeScript
          import type { NextConfig } from "next";
          
          const nextConfig: NextConfig = {
            /* config options here */
          };
          
          export default nextConfig;
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeMove the menu to use cache

            The shop has turned on Cache Components. Move getMenu from its fetch options to "use cache", keeping its hour and its tag. What does the route table print for /teas?

            Starter codeTypeScript
            const api = process.env.SUPPLIER_API_URL;
            
            export type Tea = { id: string; name: string; price: number };
            
            export async function getMenu(): Promise<Tea[]> {
              const response = await fetch(`${api}/menu`, {
                cache: "force-cache",
                next: { revalidate: 3600, tags: ["menu"] },
              });
              return response.json();
            }
            
            export async function getStock(): Promise<{ boxes: number }> {
              const response = await fetch(`${api}/stock`, { cache: "no-store" });
              return response.json();
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask two things: is the page made during the build or for each request, and is the data kept?

              The Caching before Cache Components quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Without Cache Components, a page fetches the menu with no option and reads nothing from the request. Over the build and two visits, how often is the supplier asked?

                Choose an answer to see the explanation.

              2. Question 2 of 7A page reads a cookie and fetches the menu with cache: "force-cache". What does force-cache change?

                Choose an answer to see the explanation.

              3. Question 3 of 7Two components on one page call the same fetch for the menu, with no-store. Over two visits, how often is the supplier asked?

                Choose an answer to see the explanation.

              4. Question 4 of 7This page has dynamic = "force-static". What does a visitor with the cookie favorite=mint see?

                Read the code, then predictTSX
                import { cookies } from "next/headers";
                
                const api = process.env.SUPPLIER_API_URL;
                
                export const dynamic = "force-static";
                
                export default async function TeasPage() {
                  const favorite = (await cookies()).get("favorite")?.value;
                  const response = await fetch(`${api}/menu`);
                  const teas: { id: string; name: string; price: number }[] = await response.json();
                  return (
                    <main>
                      <h1>Our teas</h1>
                      <p>{favorite ? `Your favorite: ${favorite}` : "Pick a favorite tea"}</p>
                      <ul>
                        {teas.map((tea) => (
                          <li key={tea.id}>{`${tea.name}: $${tea.price}`}</li>
                        ))}
                      </ul>
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7What did Next.js 15 change for a GET Route Handler that fetches the menu?

                Choose an answer to see the explanation.

              6. Question 6 of 7The older shop turns on Cache Components without changing getMenu, which fetches with force-cache and next.revalidate: 3600. What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 7This root layout is in an app that turns on Cache Components. What happens?

                Read the code, then predictTSX
                import Link from "next/link";
                
                export const metadata = { title: "Tea shop" };
                export const fetchCache = "default-cache";
                
                export default function RootLayout({ children }: LayoutProps<"/">) {
                  return (
                    <html lang="en">
                      <body>
                        <nav>
                          <Link href="/">Home</Link> <Link href="/teas">Teas</Link>
                        </nav>
                        {children}
                      </body>
                    </html>
                  );
                }
                

                Choose an answer to see the explanation.

              Key takeaways

              • Without Cache Components, caching is set on each fetch (cache, next.revalidate, next.tags), with unstable_cache for other functions, and for each route with dynamic, revalidate and fetchCache.
              • Whether the page is kept and whether its data is kept are separate questions: a dynamic page can read cached data, and a static page keeps uncached data inside it.
              • Four caches: request memoization for one render, the Data Cache across requests and deployments, the Full Route Cache for whole pages, and the Router Cache in the browser.
              • Next.js 15 stopped caching fetch answers, GET Route Handlers and dynamic pages in the router by default.
              • With Cache Components on, dynamic, dynamicParams, revalidate, fetchCache and runtime stop the build, while maxDuration still works; fetch options and unstable_cache keep working until you move them to "use cache", cacheLife and cacheTag.

              Remember the one-liner.
              The older model sets caching on requests and routes; Cache Components sets it on the code that does the work.

              Read it in Next.js's own code

              This link points at release v16.4.3. Where the default cache puts fetch answers is decided in getFilePath, at lines 745 to 749 of file-system-cache.ts: .next/cache/fetch-cache, "so it can be persisted across deploys", as its comment says. The course's last stage, Under the hood, reads it.

              Coming next: Parallel routes, the first lesson of the Building real apps stage: several pages in one layout, each with its own loading and error states.

              CompleteFrontend Clear concepts. Working examples.