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.
- 01Read 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.
- 02Know 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.
- 03Move 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:
lib/supplier.tsconst 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();}$ 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 demandlib/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.
fetch within one render (request memoization), keeps fetch answers in the Data Cache, and keeps whole pages in the Full Route Cache.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
fetchshared 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:
fetchanswers kept withforce-cacheandrevalidate - 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
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?
app/teas/page.tsxconst 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> );}$ 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 kBThe 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.
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:
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 wasteas.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-cachekept the menu: fetched on the first visit, and taken from the Data Cache on the second. no-storeruns 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.
| Option | What it does | In the recordings |
|---|---|---|
| No option | Fetched 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
fetchwith a shorterrevalidatethan 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, andnext devprints a warning. - Tags name answers.
next: { tags: ["menu"] }letsrevalidateTag("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:
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:
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"));
}
lib/teas.tsNext.jslib/teas.tsimport { 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"));}$ 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.jsonThree visits, three times Reading data/teas.json in what next start printed: the page is rendered for each visit, and so is getTeas.
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()orheaders(). 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"replacedunstable_cachein 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),0to 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 thecacheoption of everyfetchin 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, andforce-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.
app/teas/page.tsximport { 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> );}$ 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 demandThe 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.
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
revalidatewins 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 = 600works,revalidate = 60 * 10doesn'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:
| Option | What it does | With Cache Components on |
|---|---|---|
dynamic | Forces the route to be made for each request, or during the build. | Stops the build. |
dynamicParams | With false, answers 404 for params generateStaticParams didn't list. | Stops the build. Call notFound() instead. |
revalidate | Sets the route's lifetime, or 0 to make it for each request. | Stops the build. Use cacheLife. |
fetchCache | Changes the default cache option of the route's fetches. | Stops the build. Use "use cache". |
runtime | Picks "nodejs", the default, or the deprecated "edge". | Stops the build, even with "nodejs". Remove it. |
maxDuration | Tells the hosting platform how long server work may run. | Still works: the recorded shop built ◐ /teas. |
preferredRegion | Asks the platform for a region. Deprecated. | Not one of the options the build rejects, but the docs say to remove it. |
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:
| Cache | What it keeps | Where | How long | How to skip it |
|---|---|---|---|---|
| Request memoization | What a GET fetch returned, for the same URL and options. | Server, one render | Until that render ends. | Nothing to skip: it's React's, and lasts one render. |
| Data Cache | What a cached fetch or unstable_cache returned. | Server, on disk | Across requests and deployments, until it's revalidated. | cache: "no-store", or no option on a dynamic page. |
| Full Route Cache | A whole page: its HTML and RSC payload. | Server | Until the route is revalidated, or the next deployment. | Read the request, dynamic = "force-dynamic", or an uncached fetch. |
| Router Cache | The RSC payloads of pages the visitor opened or prefetched. | Browser, in memory | The 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:
app/teas/page.tsxconst 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> );}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:
| What | Before 15 | From 15 | To get the older way back |
|---|---|---|---|
A fetch with no cache option | Kept 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 Handler | Made 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 Cache | Reused 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.
app/api/menu/route.tsNext.jsapp/api/menu/route.tsconst api = process.env.SUPPLIER_API_URL; export async function GET() { const response = await fetch(`${api}/menu`); return Response.json(await response.json());}GET /api/menu200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-typeapplication/json
The body that arrived, as it arrived
[{"id":"green","name":"Green tea","price":4},{"id":"mint","name":"Mint tea","price":3}]ƒ /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.
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:
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:
next.config.tsNext.jsnext.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */}; export default nextConfig;Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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
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
- /
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.
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.
lib/supplier.tsconst 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();}$ 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 demandNo 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.
Step by step:
- The older shop is
ƒ /teas, as in the overview. - Cache Components on, nothing else changed. It builds:
◐ /teas 1h 1y. The migration guide says your existingfetchandunstable_cachecaching keeps working, and the recording agrees: theforce-cachemenu was fetched once, during the build, and is in the static shell, while theno-storestock streams in. - 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 withnextConfig.cacheComponents. Please remove it. Cache Components recorded the same fordynamicandrevalidate, andexport const runtime = "nodejs"stopped this shop's build too, thoughnodejsis the default.export const maxDuration = 5built. - Moved.
getMenugets"use cache",cacheLife("hours")andcacheTag("menu"), and both fetches lose their options.◐ /teas 1h 1d: the Expire time is now thehoursprofile's day, instead of the older default of a year.
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:
fetch options and unstable_cache keep working until you move them.| Older code | With Cache Components on | What to write instead |
|---|---|---|
fetch with force-cache, next.revalidate, next.tags | Keeps 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 = 3600 | Stops 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_cachekeep 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 = falseon 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 likenew Date(). - Revalidation.
revalidateTagnow takes a profile, likerevalidateTag("menu", "max"), andupdateTagis there for a Server Action whose user must see the change at once.revalidatePathis 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:
fetch(url, { cache: "force-cache" })unstable_cache(getTeas, ["teas"])export const fetchCache = "default-cache"in the root layoutexport const revalidate = 3600export const dynamicParams = falseunstable_noStore()at the top of a page
Sort each line of older code by what happens when the app turns on Cache Components.
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
ƒ, andforce-cachestill kept the menu. - "
force-staticcaches the page, and still reads the cookie." No. It makescookies()empty: every visitor gotPick a favorite tea. - "Two components fetching the same URL make two requests." No. Within one render, the second
GETgets the first one's answer. - "Turning on Cache Components breaks every
fetchoption." No. The older shop built with itsforce-cachemenu in the shell. What stops the build is five route options:dynamic,dynamicParams,revalidate,fetchCacheandruntime. - "
"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.
In a project without Cache Components, this page was built once and visited twice. How many times did the supplier get /menu?
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>
);
}
Twice, once per visit, and never during the build. no-store made the route ƒ /teas, and the recorded supplier got /menu on each of the two visits.
A visitor with the cookie favorite=mint opens this page. What does the paragraph under the heading say?
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>
);
}
Pick a favorite tea. The page was made during the build, where cookies() is empty, and every visit got that page: ○ /teas.
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?
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>
);
}
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`, { cache: "force-cache" });
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>
);
}
Once. With force-cache on line 7, the page is still ƒ /teas, but the menu was fetched on the first visit and taken from the Data Cache on the second.
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?
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"));
}
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"] },
);
Once. The recorded next start printed Reading data/teas.json once over three visits with unstable_cache, and three times without it.
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?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
staleTimes: { dynamic: 3600 },
},
};
export default nextConfig;
None. With experimental.staleTimes.dynamic at 3,600 seconds, the router reused the /teas it already had, and the recorded last click made no request. With the default, 0, it fetched /teas again.
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?
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();
}
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();
}
◐ /teas 1h 1d. The menu is cached in the shell, the stock streams in its Suspense, and the hours profile sets an Expire time of a day.
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?
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.
Question 2 of 7A page reads a cookie and fetches the menu with
cache: "force-cache". What doesforce-cachechange?Choose an answer to see the explanation.
Question 3 of 7Two components on one page call the same
fetchfor the menu, withno-store. Over two visits, how often is the supplier asked?Choose an answer to see the explanation.
Question 4 of 7This page has
dynamic = "force-static". What does a visitor with the cookiefavorite=mintsee?Read the code, then predictTSXimport { 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.
Question 5 of 7What did Next.js 15 change for a
GETRoute Handler that fetches the menu?Choose an answer to see the explanation.
Question 6 of 7The older shop turns on Cache Components without changing
getMenu, which fetches withforce-cacheandnext.revalidate: 3600. What happens?Choose an answer to see the explanation.
Question 7 of 7This root layout is in an app that turns on Cache Components. What happens?
Read the code, then predictTSXimport 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), withunstable_cachefor other functions, and for each route withdynamic,revalidateandfetchCache. - 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
fetchanswers,GETRoute Handlers and dynamic pages in the router by default. - With Cache Components on,
dynamic,dynamicParams,revalidate,fetchCacheandruntimestop the build, whilemaxDurationstill works;fetchoptions andunstable_cachekeep working until you move them to"use cache",cacheLifeandcacheTag.
Remember the one-liner.
The older model sets caching on requests and routes; Cache Components sets it on the code that does the work.
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.