Next.js · lesson 6 of 11
cacheLife and cacheTag
Custom cacheLife profiles reshape a Next.js 16 route table's Revalidate/Expire columns; cacheTag plus revalidateTag invalidate a cached value on demand.
By SkillByExample · Updated
Caching with “use cache” showed the Revalidate/Expire
columns that appear the moment a route caches real data — 15m and 1y, Next.js’s own
default. This lesson changes those two numbers on purpose, with both a built-in and a
custom cacheLife profile, then uses cacheTag to invalidate a cached value on demand
instead of waiting for either number to pass. The project setup reuses the Docker pattern
from the first lesson — the node:26.13 image, the
nextjs-modules volume, MSYS_NO_PATHCONV=1 on Git Bash — see it for why each flag is
there.
cacheLife Needs a Name next.config.ts Knows
Six files set up the project: package.json, tsconfig.json, and app/layout.tsx are
unchanged from Caching with “use cache”; app/page.tsx
carries a new comment, since this lesson’s new routes are the point, not the home page;
data-server.js is the same stand-in Node http server as before. next.config.ts is new
— it defines one custom cacheLife profile, demo, right alongside the two flags every
earlier lesson has already shown:
{
"name": "next-app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.4.3",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "20.19.43",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
cacheLife: {
demo: {
revalidate: 5,
expire: 20,
},
},
};
export default nextConfig;
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
title: "Next App",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
// app/page.tsx — a plain static home page; this lesson's new routes are /profile, /ticker, and /tagged
export default function HomePage() {
return <h1>Home</h1>;
}
// data-server.js — stands in for an external API or a database
const http = require("http");
http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ now: Date.now() }));
}).listen(4000, () => console.log("listening on 4000"));
app/profile/page.tsx calls cacheLife with a name that sounds plausible but was never
configured anywhere — not a built-in name, not demo from next.config.ts above. Predict
whether the build fails with a Next.js error naming the bad profile, or something else
entirely, before building:
// app/profile/page.tsx — "fresh" is not a configured cacheLife profile
import { cacheLife } from "next/cache";
async function getTime() {
"use cache";
cacheLife("fresh");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function ProfilePage() {
const data = await getTime();
return <p>Profile timestamp: {data.now}</p>;
}
The data source has to be reachable before anything can build or run against it, so this
capture target starts with the same two steps every build in this lesson depends on:
create a Docker network, nextjs-cachelife-net, and start data-server.js on it as its own
container, nextjs-cachelife-data. A direct curl confirms it on its own, before Next.js
ever touches it.
$ docker network create nextjs-cachelife-net
e831e3cf197692352b08d9ea7f76636972f6b7932f3450602e5d6d4e4478eb48
$ docker run -d --name nextjs-cachelife-data --network nextjs-cachelife-net -p 4100:4000 -v "$(pwd)":/root/next-app -w /root/next-app node:26.13 node data-server.js
343360effb14b83e9aa9b2754d42fcbdf846922a86afa402e8a9de5b939fa15f
$ until curl -s -o /dev/null http://localhost:4100/time; do sleep 0.3; done
$ curl -s http://localhost:4100/time
{"now":1793777596167}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund
added 28 packages in 27s
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 95ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 5.6s
Running TypeScript ...
app/profile/page.tsx(6,13): error TS2769: No overload matches this call.
The last overload gave the following error.
Type '"fresh"' has no properties in common with type '{ stale?: number | undefined; revalidate?: number | undefined; expire?: number | undefined; }'.
Failed to type check.
TypeScript, not Next.js’s own runtime check, is what actually catches this. Next.js always
generates .next/types/cache-life.d.ts from next.config.ts’s cacheLife object — even a
project with no custom profile at all gets this file, typed with every built-in name — so the
generated types restrict cacheLife to exactly the names it knows (every built-in one, plus
demo here) from the very first build, not only once a custom profile is defined. A string
that matches none of them has no valid overload — the build fails during Running TypeScript, before Next.js ever tries to render a single route. Fix the name to a real
built-in profile, minutes:
// app/profile/page.tsx — "minutes" is a built-in cacheLife profile
import { cacheLife } from "next/cache";
async function getTime() {
"use cache";
cacheLife("minutes");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function ProfilePage() {
const data = await getTime();
return <p>Profile timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 108ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.9s
Running TypeScript ...
Finished TypeScript in 2.9s ...
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
└ ○ /profile 1m 1h
○ (Static) prerendered as static content
The build succeeds, and /profile gets its own Revalidate/Expire row — 1m and 1h,
the exact numbers minutes’ built-in definition sets, nothing like the 15m/1y default
Caching with “use cache” showed. A named profile replaces
the default outright; it doesn’t adjust it.
A Custom Profile Still Has a Floor
app/ticker/page.tsx uses the custom demo profile defined in next.config.ts at the top
of this lesson — revalidate: 5, expire: 20, short enough to watch change in real time.
Predict whether a route this short-lived builds at all:
// app/ticker/page.tsx — the custom "demo" profile, no Suspense yet
import { cacheLife } from "next/cache";
async function getTime() {
"use cache";
cacheLife("demo");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function TickerPage() {
const data = await getTime();
return <p>Ticker timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 86ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.5s
Running TypeScript ...
Finished TypeScript in 2.7s ...
Error: Route "/ticker": 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
at body (<anonymous>)
at html (<anonymous>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
- Start the app in development mode by running `next dev`, then open "/ticker" in your browser to investigate the error.
- Rerun the production build with `next build --debug-prerender` to generate better stack traces.
Error occurred prerendering page "/ticker". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /ticker/page: /ticker, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
The build fails with the exact same generic error Dynamic by Default: Rendering
Modes and Caching with “use cache”
both showed for an uncached read — even though this function is wrapped in "use cache".
The installed next package enforces a floor on its own, confirmed by reading its source:
any cache entry whose expire is under five minutes can’t be baked into a static build at
all, no matter how it’s configured. demo’s expire: 20 is nowhere close. The fix is the
same [stream] option Caching with “use cache” used for
/live — wrap the read in <Suspense> instead of asking it to be fully static:
// app/ticker/page.tsx — "demo" works once the read is behind Suspense
import { Suspense } from "react";
import { cacheLife } from "next/cache";
async function getTime() {
"use cache";
cacheLife("demo");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
async function Ticker() {
const data = await getTime();
return <p>Ticker timestamp: {data.now}</p>;
}
export default function TickerPage() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Ticker />
</Suspense>
);
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 90ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.6s
Running TypeScript ...
Finished TypeScript in 2.3s ...
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ○ /profile 1m 1h
└ ◐ /ticker
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/ticker builds now, marked ◐ with no Revalidate/Expire columns of its own — same as
/live in Caching with “use cache”. There’s nothing to
show in the route table because nothing was baked into the static build; demo’s numbers
only govern what happens once the app is actually serving requests, which is what the next
section checks for real.
Time-Based Revalidation, Observed for Real
Start the app and request /ticker three times — once, again two seconds later, and again
eight seconds after that — predicting demo’s revalidate: 5 draws the line between the
first pair and the second:
$ docker run -d --name nextjs-cachelife-1 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
00eeeb3ea552dd6747add18a63b803fb9a5e265d722722b74f25dd92e949f95c
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ a=$(curl -s http://localhost:3100/ticker | grep -oE '<p>Ticker timestamp: [^/]+/p>'); echo "$a"
<p>Ticker timestamp: <!-- -->1793777688249</p>
$ sleep 2; b=$(curl -s http://localhost:3100/ticker | grep -oE '<p>Ticker timestamp: [^/]+/p>'); echo "$b"
<p>Ticker timestamp: <!-- -->1793777688249</p>
$ [ "$a" = "$b" ] && echo SAME
SAME
$ sleep 8; c=$(curl -s http://localhost:3100/ticker | grep -oE '<p>Ticker timestamp: [^/]+/p>'); echo "$c"
<p>Ticker timestamp: <!-- -->1793777699438</p>
$ [ "$a" != "$c" ] && echo DIFFERENT
DIFFERENT
$ docker rm -f nextjs-cachelife-1
nextjs-cachelife-1
$a and $b, two seconds apart, print SAME — well inside the five-second revalidate
window, so the same baked value answers both. $c, ten seconds after $a, prints
DIFFERENT — past the window, the next request gets a freshly computed value instead of
the stale one. demo’s expire: 20 never even comes into play here: with a request landing
every few seconds, revalidate keeps refreshing the entry long before expire’s “no
traffic at all” clock could ever run out.
cacheTag and On-Demand Invalidation
app/tagged/page.tsx adds one more call next to cacheLife: cacheTag("time"), which
labels this cached value so it can be invalidated by name later, independent of minutes’
own one-minute revalidate window. app/api/invalidate/route.ts is a Route Handler meant
to trigger that invalidation — its first version reaches for updateTag, which Next.js
16.4.3 also exports from next/cache:
// app/tagged/page.tsx — cacheTag labels this value for later on-demand invalidation
import { cacheLife, cacheTag } from "next/cache";
async function getTime() {
"use cache";
cacheLife("minutes");
cacheTag("time");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function TaggedPage() {
const data = await getTime();
return <p>Tagged timestamp: {data.now}</p>;
}
// app/api/invalidate/route.ts — updateTag, called from a Route Handler
import { updateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST() {
updateTag("time");
return NextResponse.json({ invalidated: true });
}
Predict whether POSTing this route actually invalidates /tagged, before building and
running it:
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 78ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.7s
Running TypeScript ...
Finished TypeScript in 3.2s ...
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile 1m 1h
├ ○ /tagged 1m 1h
└ ◐ /ticker
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-cachelife-2 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
3a90ab6a36e96f22de5a6877a05deca6863f87731d4b027279dfa9ef0d679d71
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3100/api/invalidate
HTTP/1.1 500 Internal Server Error
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
Date: Wed, 04 Nov 2026 07:35:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
$ docker logs nextjs-cachelife-2 2>&1 | tail -3
⨯ Error: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag
at b (.next/server/chunks/[root-of-the-server]__0lyk55rbj4nd-._.js:74:10745)
at n (.next/server/chunks/[root-of-the-server]__0lyk55rbj4nd-._.js:1:98)
$ docker rm -f nextjs-cachelife-2
nextjs-cachelife-2
The build itself succeeds — next build never calls a dynamic Route Handler’s function, so
there’s nothing to fail yet. The POST request is a different story: a bare 500, no
body, exactly like Route Handlers showed for an uncaught
exception. The server’s own log names the real reason: updateTag only works inside a
Server Action, not a Route Handler. Swap it for revalidateTag, which does work here. Its
required second argument is a cacheLife profile name that decides what happens next:
"max" is Next.js’s own stale-while-revalidate profile — the very next request to a tagged
route still gets served the last cached value, and that same request is what triggers
Next.js to recompute it in the background, so only the request after that one sees the
fresh value:
// app/api/invalidate/route.ts — revalidateTag, called from a Route Handler
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST() {
revalidateTag("time", "max");
return NextResponse.json({ invalidated: true });
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 93ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.6s
Running TypeScript ...
Finished TypeScript in 2.6s ...
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile 1m 1h
├ ○ /tagged 1m 1h
└ ◐ /ticker
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-cachelife-3 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
73774a6211c1b9c507ac51e31522514fafbab474a05cb5bab9bc108abfcdec5a
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ p=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$p"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ q=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$q"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ [ "$p" = "$q" ] && echo SAME
SAME
$ curl -s -X POST http://localhost:3100/api/invalidate; echo
{"invalidated":true}
$ r=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$r"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ [ "$p" = "$r" ] && echo SAME
SAME
$ sleep 2; s=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$s"
<p>Tagged timestamp: <!-- -->1793777751524</p>
$ [ "$p" != "$s" ] && echo DIFFERENT
DIFFERENT
$ docker rm -f nextjs-cachelife-3
nextjs-cachelife-3
$p and $q, requested back to back with no invalidation at all, print SAME — minutes’
one-minute revalidate window is nowhere close to elapsing on its own. After the POST,
$r — requested immediately — still matches $p: that’s "max"’s stale-while-revalidate
behavior specifically, not something invalidation does on its own. Marking the tag stale
doesn’t recompute anything by itself; it’s $r’s own request that triggers the
recomputation, and $r itself is still served the old, stale value while that recompute
runs in the background. Only $s, requested two seconds later once that background
recompute has actually finished, prints DIFFERENT. cacheTag and revalidateTag
invalidate on demand, by name, independent of whatever revalidate/expire numbers the
cached function’s own profile set — but with the "max" profile, “on demand” still means
“starting next request,” not “instantly.” The FAQ below covers the other profile shape,
{ expire: 0 }, which does expire immediately instead.
Practice
Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order. The last task also removes the data source and network this lesson created.
1. Add cacheTag("time") to /profile too, so it shares the same tag /tagged already
uses. Confirm that a single POST to /api/invalidate changes both routes’ values, not
just /tagged’s.
Solution
// app/profile/page.tsx — now shares the "time" tag with /tagged
import { cacheLife, cacheTag } from "next/cache";
async function getTime() {
"use cache";
cacheLife("minutes");
cacheTag("time");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function ProfilePage() {
const data = await getTime();
return <p>Profile timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 89ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 3.9s
Running TypeScript ...
Finished TypeScript in 2.1s ...
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile 1m 1h
├ ○ /tagged 1m 1h
└ ◐ /ticker
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
$ docker run -d --name nextjs-cachelife-4 --network nextjs-cachelife-net -p 3100:3000 -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 node:26.13 npx next start
eee4dd9995a9a1badec05c1c1bb34fcdf4da5eb510a4b460629d9930677617ef
$ until curl -s -o /dev/null http://localhost:3100/; do sleep 0.3; done
$ p1=$(curl -s http://localhost:3100/profile | grep -oE '<p>Profile timestamp: [^/]+/p>'); echo "$p1"
<p>Profile timestamp: <!-- -->1793777652016</p>
$ t1=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$t1"
<p>Tagged timestamp: <!-- -->1793777652016</p>
$ curl -s -X POST http://localhost:3100/api/invalidate; echo
{"invalidated":true}
$ curl -s http://localhost:3100/profile >/dev/null; curl -s http://localhost:3100/tagged >/dev/null
$ sleep 2
$ p2=$(curl -s http://localhost:3100/profile | grep -oE '<p>Profile timestamp: [^/]+/p>'); echo "$p2"
<p>Profile timestamp: <!-- -->1793777778625</p>
$ t2=$(curl -s http://localhost:3100/tagged | grep -oE '<p>Tagged timestamp: [^/]+/p>'); echo "$t2"
<p>Tagged timestamp: <!-- -->1793777778770</p>
$ [ "$p1" != "$p2" ] && echo "profile DIFFERENT"
profile DIFFERENT
$ [ "$t1" != "$t2" ] && echo "tagged DIFFERENT"
tagged DIFFERENT
$ docker rm -f nextjs-cachelife-4
nextjs-cachelife-4
$p1 and $t1 start out identical, even though they’re two different routes’ own cached
functions: both fetch the exact same URL, http://nextjs-cachelife-data:4000/time, during
the same build, and Caching with “use cache” already
showed Next.js caching the inner fetch() call by URL — so /profile and /tagged share
one fetch result, not two separate ones. The throwaway requests right after the POST
(curl ... >/dev/null, discarding their bodies) and the sleep 2 that follows aren’t idle
padding: with "max"’s stale-while-revalidate behavior, it takes a request to actually
trigger the background recompute, and that recompute needs a moment to finish before a
later request can see it — the two discarded curls are what trigger /profile’s and
/tagged’s recomputes, and the sleep gives both time to finish before $p2/$t2 ask
again. Both then print DIFFERENT. One revalidateTag("time", "max") call invalidates
every cache entry carrying that tag, regardless of which route or which function originally
computed it — cacheTag groups entries by label, not by route.
2. Call revalidateTag with only its first argument, the way its own deprecation
message suggests was once allowed, and predict whether that produces the runtime warning
the message describes or something else.
Solution
// app/api/invalidate/route.ts — revalidateTag with only one argument
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST() {
revalidateTag("time");
return NextResponse.json({ invalidated: true });
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 104ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.4s
Running TypeScript ...
app/api/invalidate/route.ts(6,3): error TS2554: Expected 2 arguments, but got 1.
Failed to type check.
No warning ever prints, because the build never gets far enough to run it: revalidateTag’s
TypeScript signature requires the second argument, so leaving it out fails type checking
with error TS2554: Expected 2 arguments, but got 1. The runtime deprecation warning in
Next.js’s own source is real, but a TypeScript project can never actually trigger it —
exactly like cacheLife’s unknown-profile check earlier in this lesson, the compile-time
type and the runtime check both exist, and the stricter one always wins first.
3. Replace /profile’s cacheLife("minutes") with an inline profile object,
cacheLife({ revalidate: 30, expire: 600 }) — no change to next.config.ts needed — and
confirm the route table reflects the new numbers directly.
Solution
// app/api/invalidate/route.ts — back to the correct two-argument call
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST() {
revalidateTag("time", "max");
return NextResponse.json({ invalidated: true });
}
// app/profile/page.tsx — an inline cacheLife profile instead of a named one
import { cacheLife, cacheTag } from "next/cache";
async function getTime() {
"use cache";
cacheLife({ revalidate: 30, expire: 600 });
cacheTag("time");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function ProfilePage() {
const data = await getTime();
return <p>Profile timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 75ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.7s
Running TypeScript ...
Finished TypeScript in 3.0s ...
Finalizing page optimization ...
Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/invalidate
├ ○ /profile 30s 10m
├ ○ /tagged 1m 1h
└ ◐ /ticker
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demand
/profile’s row now reads 30s and 10m — exactly the two numbers passed inline, with no
entry in next.config.ts at all. A name is only a shortcut to a profile object defined once
and reused; the object form works anywhere a name does, as long as its expire still clears
the five-minute floor from earlier in this lesson.
4. Remove the <Suspense> wrapper from /ticker, keeping cacheLife("demo"), and
confirm it fails the exact same way it did before Suspense was added the first time. Then
remove the data source and the network this lesson created.
Solution
// app/ticker/page.tsx — Suspense removed again, "demo" is still too short to prerender
import { cacheLife } from "next/cache";
async function getTime() {
"use cache";
cacheLife("demo");
const res = await fetch("http://nextjs-cachelife-data:4000/time");
return res.json();
}
export default async function TickerPage() {
const data = await getTime();
return <p>Ticker timestamp: {data.now}</p>;
}
$ docker run --rm --network nextjs-cachelife-net -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using|Finished filesystem cache database compaction"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 95ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.8s
Running TypeScript ...
Finished TypeScript in 2.8s ...
Error: Route "/ticker": 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
at body (<anonymous>)
at html (<anonymous>)
To get a more detailed stack trace and pinpoint the issue, try one of the following:
- Start the app in development mode by running `next dev`, then open "/ticker" in your browser to investigate the error.
- Rerun the production build with `next build --debug-prerender` to generate better stack traces.
Error occurred prerendering page "/ticker". Read more: https://nextjs.org/docs/messages/prerender-error
Export encountered an error on /ticker/page: /ticker, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
$ docker rm -f nextjs-cachelife-data
nextjs-cachelife-data
$ docker network rm nextjs-cachelife-net
nextjs-cachelife-net
Same generic “encountered uncached or runtime data” error as the very first time this
lesson tried it — the five-minute floor doesn’t care whether the short profile is new or
already proven to work behind <Suspense>; remove the boundary and the floor applies all
over again. The last two steps remove the data source and the network this lesson created.