Emmanuel Akinfulubi

Data all 4 written

Fetching on the server, the default caching model counted in requests, on-demand revalidation, and Cache Components.

13 Fetching data on the server

In a server component, getting data is an await. There is no effect to write, no loading flag, and no place for the browser to send a request: the component runs on the server and asks for the data from there.

An await in a component

The page below fetches a list of posts from the data server the course runs next to next start (a small API on 127.0.0.1 that counts the requests it gets, so the panels can count too). It waits with await twice, the way the Node.js course does outside any framework: once for the response and once for its body. It starts with await connection() (lesson 11), so it runs for each request and not once at build time. The data server's address comes from an environment variable that only the server process has.

fetch inside a server component

ran at build time · next build + next start · Next 16.3.1

app/data/basic/page.js

import { connection } from 'next/server';

export default async function Basic() {
  await connection();
  const res = await fetch(`${process.env.DATA_URL}/posts`);
  const posts = await res.json();
  return (
    <main data-page="posts">
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}

basic.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const hits = async () => (await fetch(data + '/_hits')).json();

const html = await (await fetch(base + '/data/basic')).text();
const afterOne = await hits();
await fetch(base + '/data/basic');
const afterTwo = await hits();

console.log(html.match(/<ul>.*?<\/ul>/)[0]);
console.log('requests that reached the data server after one visit: ', JSON.stringify(afterOne));
console.log('requests that reached the data server after two visits:', JSON.stringify(afterTwo));

check('the titles fetched on the server are in the HTML', ['Hello, Next.js', 'Server components', 'Caching'].map((t) => html.includes(t)), [true, true, true]);
check('one visit made one request to the data server', afterOne, { '/posts': 1 });
check('a second visit made another: this fetch is not remembered between requests', afterTwo, { '/posts': 2 });
check('the address of the data server never reaches the browser', html.includes(data), false);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node basic.mjs

<ul><li>Hello, Next.js</li><li>Server components</li><li>Caching</li></ul>
requests that reached the data server after one visit:  {"/posts":1}
requests that reached the data server after two visits: {"/posts":2}
ok   the titles fetched on the server are in the HTML
ok   one visit made one request to the data server
ok   a second visit made another: this fetch is not remembered between requests
ok   the address of the data server never reaches the browser

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The browser got the list as HTML and never talked to the data server. The count is the point: each visit made one request, and the second did not reuse the first. In Next.js 16, with the default configuration this course uses, a fetch result is not kept between requests unless you ask. Lesson 14 shows how to ask, and counts what changes.

Waiting for one thing after another

Two awaits in a row run one after the other. If the second request does not need the first's answer, that is wasted time, a waterfall. Starting both before waiting for either, with Promise.all, runs them together. The data server logs when each request arrived, in milliseconds from the first, so the difference is a number it measured and not a feeling. Each of the two requests takes 600 ms to answer.

A waterfall and Promise.all, measured by the data server

ran at build time · next build + next start · Next 16.3.1

app/data/waterfall/page.js

import { connection } from 'next/server';

const get = async (id) => (await fetch(`${process.env.DATA_URL}/slow?ms=600&id=${id}`)).json();

export default async function Waterfall() {
  await connection();
  const a = await get('a');
  const b = await get('b');
  return <main data-page="waterfall">{a.waited + b.waited} ms of waiting, one after the other</main>;
}

app/data/parallel/page.js

import { connection } from 'next/server';

const get = async (id) => (await fetch(`${process.env.DATA_URL}/slow?ms=600&id=${id}`)).json();

export default async function Parallel() {
  await connection();
  const [a, b] = await Promise.all([get('a'), get('b')]);
  return <main data-page="parallel">{a.waited + b.waited} ms of waiting, at the same time</main>;
}

waterfall.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const log = async () => (await fetch(data + '/_log')).json();

await fetch(base + '/data/waterfall');
const sequential = await log();
await fetch(data + '/_reset');
await fetch(base + '/data/parallel');
const together = await log();

const gap = (rows) => rows[1][1] - rows[0][1];
console.log('one after the other:', sequential.map((row) => row[0]).join('  then  '));
console.log('  the second arrived at least 550 ms after the first:', gap(sequential) >= 550);
console.log('with Promise.all:');
console.log('  both arrived within 300 ms of each other:', gap(together) < 300);

check('the waterfall asked in order', sequential.map((row) => row[0]), ['/slow?ms=600&id=a', '/slow?ms=600&id=b']);
check('and asked for the second only after the first had answered', gap(sequential) >= 550, true);
check('Promise.all asked for both', together.map((row) => row[0]).sort(), ['/slow?ms=600&id=a', '/slow?ms=600&id=b']);
check('at once', gap(together) < 300, true);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node waterfall.mjs

one after the other: /slow?ms=600&id=a  then  /slow?ms=600&id=b
  the second arrived at least 550 ms after the first: true
with Promise.all:
  both arrived within 300 ms of each other: true
ok   the waterfall asked in order
ok   and asked for the second only after the first had answered
ok   Promise.all asked for both
ok   at once

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The two requests differ by an id parameter on purpose. Two fetch calls for the same address in one render are treated as one, as the next panel shows, so a Promise.all of identical requests would not prove anything about parallelism.

The same request twice in one render

A component tree often needs the same data in two places: a heading that counts the posts and a list that shows them. Each component can fetch for itself, without passing the data down. Next.js remembers a fetch of the same address, with the same options, for the length of one render, so the data server sees one request. The memory is dropped when the render ends, which is why the previous panel's second visit made a second request.

That memory is for fetch. For other work, such as a function that reads a database, React's cache() gives the same behaviour for a function. The page calls a plain function twice and a cache()-wrapped one twice, and prints how many times each body ran.

Request memoization, and cache() for everything else

ran at build time · next build + next start · Next 16.3.1

app/data/memo/page.js

import { connection } from 'next/server';
import { calls, double, doubleCached } from '../../_lib/calls.js';

async function PostCount() {
  const posts = await (await fetch(`${process.env.DATA_URL}/posts`)).json();
  return <span data-count>{posts.length} posts</span>;
}

async function PostTitles() {
  const posts = await (await fetch(`${process.env.DATA_URL}/posts`)).json();
  return <span data-titles>{posts.map((post) => post.title).join(', ')}</span>;
}

export default async function Memo() {
  await connection();
  double(2);
  double(2);
  doubleCached(2);
  doubleCached(2);
  return (
    <main data-page="memo" data-plain={calls.plain} data-cached={calls.cached}>
      <PostCount /> <PostTitles />
    </main>
  );
}

app/_lib/calls.js

import { cache } from 'react';

export const calls = { plain: 0, cached: 0 };

export function double(n) {
  calls.plain += 1;
  return n * 2;
}

export const doubleCached = cache((n) => {
  calls.cached += 1;
  return n * 2;
});

memo.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const hits = async () => (await fetch(data + '/_hits')).json();
const number = (html, name) => Number(new RegExp('data-' + name + '="(\\d+)"').exec(html)[1]);

const first = await (await fetch(base + '/data/memo')).text();
const afterOne = await hits();
const second = await (await fetch(base + '/data/memo')).text();
const afterTwo = await hits();

console.log('two components fetched /posts; requests that reached the data server:', JSON.stringify(afterOne));
console.log('after a second visit:', JSON.stringify(afterTwo));
console.log('per visit, the plain function ran', number(second, 'plain') - number(first, 'plain'), 'times for 2 calls');
console.log('per visit, the cache()-wrapped one ran', number(second, 'cached') - number(first, 'cached'), 'time for 2 calls');

check('two components fetching one address made one request', afterOne, { '/posts': 1 });
check('the memory ends with the render, so the next visit asked again', afterTwo, { '/posts': 2 });
check('a plain function runs for every call', number(second, 'plain') - number(first, 'plain'), 2);
check('cache() runs it once per render', number(second, 'cached') - number(first, 'cached'), 1);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node memo.mjs

two components fetched /posts; requests that reached the data server: {"/posts":1}
after a second visit: {"/posts":2}
per visit, the plain function ran 2 times for 2 calls
per visit, the cache()-wrapped one ran 1 time for 2 calls
ok   two components fetching one address made one request
ok   the memory ends with the render, so the next visit asked again
ok   a plain function runs for every call
ok   cache() runs it once per render

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Fetching in a client component

A client component runs in the browser, and a request it makes comes from the browser. It cannot use the server's environment variables, and it can only reach addresses the browser can. The usual route is an endpoint of your own, a route handler (lesson 19), called from an effect or from a library built for it. The server-component version above is simpler whenever the data does not depend on something that only changes in the browser.

A client component that fetches from an endpoint of its own

shown, not run
'use client';
import { useEffect, useState } from 'react';

export default function Posts() {
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetch('/api/posts')
      .then((res) => res.json())
      .then(setPosts);
  }, []);

  if (!posts) return <p>Loading...</p>;
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

the effect runs in a browser after hydration, and the project has no /api/posts handler (route handlers come in lesson 19).

The React course covers the other way React offers to read a promise in a component, the use hook, and the Node.js course covers fetch itself.

14 Caching and revalidation (the default model)

Every request that reaches your data source costs time and money, and the same answer is often good for a while. Caching is deciding which answers to keep and for how long. This lesson is about the model Next.js 16 uses when cacheComponents is off, which is the default and the one every project in this course uses until lesson 16. The fetch calls below go to the course's data server, whose /stamp/<name> address answers with how many times that address has been asked for, so a page can show which request its data came from.

The vendored guide for this model is node_modules/next/dist/docs/01-app/02-guides/caching-without-cache-components.md. The panels check what it says, in numbers. The fetch they use is the one the Node.js course covers; Next.js adds options to its second argument.

Nothing is remembered unless you ask

The two pages below differ in one option. Both start with await connection() (lesson 11), so they run for each visit and not once at build time. The first calls fetch(url) and the second adds cache: 'force-cache', which tells Next.js to keep the response and answer later calls for the same request from its copy.

The same fetch with and without force-cache

ran at build time · next build + next start · Next 16.3.1

app/cache/default/page.js

import { connection } from 'next/server';

export default async function Default() {
  await connection();
  const res = await fetch(`${process.env.DATA_URL}/stamp/default`);
  const stamp = await res.json();
  return (
    <main data-page="cache-default">
      <p>This page was built from data request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

app/cache/forced/page.js

import { connection } from 'next/server';

export default async function Forced() {
  await connection();
  const res = await fetch(`${process.env.DATA_URL}/stamp/forced`, { cache: 'force-cache' });
  const stamp = await res.json();
  return (
    <main data-page="cache-forced">
      <p>This page was built from data request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

force-cache.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = (html) => Number(/data-n[^>]*>(\d+)</.exec(html)[1]);
const visit = async (path) => number(await (await fetch(base + path)).text());

const plain = [await visit('/cache/default'), await visit('/cache/default'), await visit('/cache/default')];
const forced = [await visit('/cache/forced'), await visit('/cache/forced'), await visit('/cache/forced')];
const hits = await (await fetch(data + '/_hits')).json();

console.log('fetch(url)                        three visits showed data request number:', plain.join(', '));
console.log("fetch(url, { cache: 'force-cache' }) three visits showed data request number:", forced.join(', '));
console.log('requests that reached the data server:', JSON.stringify(hits));

check('without an option, every visit made a new request', plain, [1, 2, 3]);
check('with force-cache, the first answer was reused', forced, [1, 1, 1]);
check('the data server counted 3 requests and 1', hits, { '/stamp/default': 3, '/stamp/forced': 1 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node force-cache.mjs

fetch(url)                        three visits showed data request number: 1, 2, 3
fetch(url, { cache: 'force-cache' }) three visits showed data request number: 1, 1, 1
requests that reached the data server: {"/stamp/default":3,"/stamp/forced":1}
ok   without an option, every visit made a new request
ok   with force-cache, the first answer was reused
ok   the data server counted 3 requests and 1

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Without the option, three visits made three requests. With it, the first answer was reused and the data server was asked once. A copy that is never replaced is right only for data that never changes, so the next option gives it a lifetime.

Remembered for a while

next: { revalidate: 2 } keeps the response for two seconds. The panel visits twice at once, waits 3.5 seconds, which leaves a margin over the 2, and then visits until the new answer shows. It prints booleans and counts, never milliseconds: the data server's count is what proves that the data was fetched once more and not once per visit.

next: { revalidate: 2 }

ran at build time · next build + next start · Next 16.3.1

app/cache/timed/page.js

import { connection } from 'next/server';

export default async function Timed() {
  await connection();
  const res = await fetch(`${process.env.DATA_URL}/stamp/timed`, { next: { revalidate: 2 } });
  const stamp = await res.json();
  return (
    <main data-page="cache-timed">
      <p>This page was built from data request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

timed.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = (html) => Number(/data-n[^>]*>(\d+)</.exec(html)[1]);
const visit = async () => number(await (await fetch(base + '/cache/timed')).text());
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const early = [await visit(), await visit()];
await sleep(3500);
const later = [];
for (let i = 0; i < 6 && later.at(-1) !== 2; i++) {
  later.push(await visit());
  await sleep(300);
}
const hits = await (await fetch(data + '/_hits')).json();

console.log('two visits at once showed:', early.join(', '));
console.log('after 3.5 seconds the data had changed within 6 visits:', later.at(-1) === 2);
console.log('requests that reached the data server:', JSON.stringify(hits));

check('two visits inside the 2 seconds shared one answer', early, [1, 1]);
check('after the time was up the data was fetched again', later.at(-1), 2);
check('and that was one new request, not one per visit', hits, { '/stamp/timed': 2 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node timed.mjs

two visits at once showed: 1, 1
after 3.5 seconds the data had changed within 6 visits: true
requests that reached the data server: {"/stamp/timed":2}
ok   two visits inside the 2 seconds shared one answer
ok   after the time was up the data was fetched again
ok   and that was one new request, not one per visit

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Real code usually reads from a database or a file, not from fetch, and a fetch option cannot help there. unstable_cache from next/cache wraps any async function. Its second argument is a list of strings that names the entry, and its third holds the same revalidate (seconds) and tags (lesson 15) as the fetch option.

unstable_cache

The function below counts how many times its body really runs. The page prints the count in an attribute, so three visits show whether the function ran three times or once. In the Cache Components model of lesson 16, "use cache" does this job with a directive instead of a wrapper.

unstable_cache around a function that is not a fetch

ran at build time · next build + next start · Next 16.3.1

app/_lib/report.js

import { unstable_cache } from 'next/cache';

// Counts how many times the function below really ran.
export const runs = { count: 0 };

// A function that is not a fetch (a database query, say), remembered by Next.js.
export const getReport = unstable_cache(
  async (id) => {
    runs.count += 1;
    return { id, title: `Report ${id}` };
  },
  ['report'],
  { revalidate: 3600, tags: ['report'] },
);

app/cache/unstable/page.js

import { connection } from 'next/server';
import { getReport, runs } from '../../_lib/report.js';

export default async function Unstable() {
  await connection();
  const report = await getReport('weekly');
  return (
    <main data-page="cache-unstable" data-runs={runs.count}>
      <p>{report.title}</p>
    </main>
  );
}

unstable.mjs

const base = process.env.BASE_URL;
const runs = async () => Number(/data-runs="(\d+)"/.exec(await (await fetch(base + '/cache/unstable')).text())[1]);

const seen = [await runs(), await runs(), await runs()];
console.log('times the function body had run after each of 3 visits:', seen.join(', '));

check('the function ran once and its answer was reused', seen, [1, 1, 1]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node unstable.mjs

times the function body had run after each of 3 visits: 1, 1, 1
ok   the function ran once and its answer was reused

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The body ran once and the later visits were answered from the stored result.

A page that is never dynamic

The pages above all call connection(), so they cannot be built ahead. A page that reads nothing from the request is different: Next.js renders it once during next build, and its fetch runs then. Lesson 11 showed this with a clock. Here the data server counts it: the build panel asks how many requests the build made, and the second panel visits the page three times.

The build also lists the pages that carry a revalidation time. When a page has one, the route table gets two more columns, Revalidate and Expire, and the page /cache/isr below has export const revalidate = 2.

What the build asked the data server for

ran at build time · next build · Next 16.3.1

at-build.mjs

import { readFileSync } from 'node:fs';
const hits = await (await fetch(process.env.DATA_URL + '/_hits')).json();
const manifest = JSON.parse(readFileSync(process.env.APP_DIR + '/.next/prerender-manifest.json', 'utf8'));
console.log('requests the build made:', JSON.stringify({ static: hits['/stamp/static'], isr: hits['/stamp/isr'], default: hits['/stamp/default'] ?? 0 }));
console.log('prerender-manifest.json for /cache/isr:', JSON.stringify(manifest.routes['/cache/isr']));
check('the build fetched the data of a static page once', hits['/stamp/static'], 1);
check('and the data of a revalidate = 2 page once', hits['/stamp/isr'], 1);
check('a dynamic page fetched nothing at build time', hits['/stamp/default'], undefined);
check('revalidate = 2 is recorded as 2 seconds, and the entry may be kept for a year', [manifest.routes['/cache/isr'].initialRevalidateSeconds, manifest.routes['/cache/isr'].initialExpireSeconds], [2, 31536000]);

What next build printed (Next 16.3.1), then the checks, when the build ran it

next build

▲ Next.js 16.3.1 (Turbopack)
- Environments: .env.local, .env.production, .env
✓ Running next.config.mjs took <t>

  Creating an optimized production build ...
✓ Compiled successfully in <t>
  Running TypeScript ...
  Finished TypeScript in <t> ...
  Collecting page data using <n> workers ...
✓ Generating static pages using <n> workers (59/59) in <t>
  Finalizing page optimization ...

Route (app)               Revalidate  Expire
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ƒ /account
├ ƒ /actions
├ ○ /actions/done
├ ƒ /api/books
├ ƒ /api/books/[id]
├ ƒ /api/cookie
├ ƒ /api/hello
├ ƒ /api/me
├ ƒ /api/revalidate
├ ○ /api/static
├ ƒ /api/time
├ ƒ /authors/[name]
├ ○ /books
├   /books/[slug]
│ ├ ● /books/middlemarch
│ ├ ● /books/dracula
│ └ ● /books/persuasion
├ ƒ /cache/default
├ ƒ /cache/forced
├ ○ /cache/isr                    2s      1y
├ ○ /cache/static
├ ƒ /cache/timed
├ ƒ /cache/unstable
├ ƒ /clock/connection
├ ƒ /clock/cookies
├ ƒ /clock/dynamic
├ ƒ /clock/search
├ ○ /clock/static
├ ○ /counter
├ ○ /dashboard
├ ○ /dashboard/settings
├ ƒ /data/basic
├ ƒ /data/memo
├ ƒ /data/parallel
├ ƒ /data/waterfall
├ ƒ /docs/[...parts]
├ ƒ /env
├ ○ /env/baked
├ ○ /env/client
├ ○ /env/leak
├ ƒ /flaky
├ ○ /forms/signup
├ ƒ /forms/thanks
├ ƒ /guide/[[...parts]]
├ ○ /images
├ ○ /library
├ ƒ /login
├ ○ /meta
├ ƒ /meta/[slug]
├ ○ /moved
├ ○ /old-shelf
├ ○ /pricing
├ ƒ /proxy-demo/new
├ ƒ /reports
├ ƒ /request
├ ƒ /request/bad-set
├ ○ /robots.txt
├ ƒ /signup
├ ○ /sitemap.xml
├ ƒ /streaming
├ ƒ /streaming/blocking
├ ○ /studio
├ ○ /styled
├ ƒ /tags/action
├ ƒ /tags/list
└ ○ /tags/path


ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

ok   the log shows "○ /cache/static"
ok   the log shows "○ /cache/isr"
ok   the log shows "Revalidate  Expire"
ok   the log shows "2s      1y"
requests the build made: {"static":1,"isr":1,"default":0}
prerender-manifest.json for /cache/isr: {"routeType":"page","response":"complete","compute":"static","htmlSize":5232,"experimentalBypassFor":[{"type":"header","key":"next-action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"}],"initialRevalidateSeconds":2,"initialExpireSeconds":31536000,"srcRoute":"/cache/isr","dataRoute":"/cache/isr.rsc","allowHeader":["host","x-matched-path","x-prerender-revalidate","x-prerender-revalidate-if-generated","x-next-revalidated-tags","x-next-revalidate-tag-token"]}
ok   the build fetched the data of a static page once
ok   and the data of a revalidate = 2 page once
ok   a dynamic page fetched nothing at build time
ok   revalidate = 2 is recorded as 2 seconds, and the entry may be kept for a year

The check script ran after the build, with APP_DIR set to the project, and read the files in .next.

Visits to a page that was built ahead

ran at build time · next build + next start · Next 16.3.1

app/cache/static/page.js

export default async function Static() {
  const res = await fetch(`${process.env.DATA_URL}/stamp/static`);
  const stamp = await res.json();
  return (
    <main data-page="cache-static">
      <p>This page was built from data request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

static.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const get = async (path) => {
  const res = await fetch(base + path);
  return { n: Number(/data-n[^>]*>(\d+)</.exec(await res.text())[1]), cache: res.headers.get('x-nextjs-cache') };
};
const visits = [await get('/cache/static'), await get('/cache/static'), await get('/cache/static')];
const hits = await (await fetch(data + '/_hits')).json();
console.log('three visits showed data request number:', visits.map((v) => v.n).join(', '));
console.log('x-nextjs-cache:', visits.map((v) => v.cache).join(', '));
console.log('requests that reached the data server:', JSON.stringify(hits));
check('every visit got the data fetched at build time', visits.map((v) => v.n), [1, 1, 1]);
check('no visit made a request', hits, {});
check('Next.js says the copy came from its own store', visits.map((v) => v.cache), ['HIT', 'HIT', 'HIT']);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node static.mjs

three visits showed data request number: 1, 1, 1
x-nextjs-cache: HIT, HIT, HIT
requests that reached the data server: {}
ok   every visit got the data fetched at build time
ok   no visit made a request
ok   Next.js says the copy came from its own store

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The build asked for the data of both static pages once. Three visits to the first one were answered from the copy made at build time: no request reached the data server, and x-nextjs-cache: HIT is Next.js saying so.

A built page that expires

A page built once is right until something changes, and the build is the only time it was made. export const revalidate = 2 gives the page a lifetime of two seconds, like the fetch option, and the page is still built ahead. The route table shows the numbers (2s and 1y above), and so does prerender-manifest.json, which the build panel prints and checks.

What happens when the time is up is the interesting part. The panel waits long enough that the build-time copy is more than two seconds old, then visits. The visit does not wait for a new page: it is handed the old copy, marked STALE, and that visit makes Next.js build a new copy in the background. A visit after that gets the new copy, marked HIT. This is stale-while-revalidate: nobody waits for a render, and the cost is that the first visitor after the time is up sees the old page. The panel then waits 3.5 seconds and shows the same sequence again, and the data server counts one request for each new copy.

export const revalidate = 2 on a static page

ran at build time · next build + next start · Next 16.3.1

app/cache/isr/page.js

export const revalidate = 2;

export default async function Isr() {
  const res = await fetch(`${process.env.DATA_URL}/stamp/isr`);
  const stamp = await res.json();
  return (
    <main data-page="cache-isr">
      <p>This page was built from data request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

isr.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const get = async () => {
  const res = await fetch(base + '/cache/isr');
  return { n: Number(/data-n[^>]*>(\d+)</.exec(await res.text())[1]), cache: res.headers.get('x-nextjs-cache') };
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const untilNumber = async (wanted) => {
  const seen = [];
  for (let i = 0; i < 8 && seen.at(-1)?.n !== wanted; i++) { await sleep(300); seen.push(await get()); }
  return seen.at(-1);
};

await sleep(2500);
const stale = await get();
const fresh = await untilNumber(2);
const midway = await fetch(data + '/_hits').then((res) => res.json());
await sleep(3500);
const staleAgain = await get();
const freshAgain = await untilNumber(3);
const hits = await (await fetch(data + '/_hits')).json();

console.log('first visit, more than 2 seconds after the build:', JSON.stringify(stale));
console.log('a visit once the new copy was ready:', JSON.stringify(fresh));
console.log('requests that reached the data server by then:', JSON.stringify(midway));
console.log('first visit after waiting 3.5 seconds:', JSON.stringify(staleAgain));
console.log('a visit once the next copy was ready:', JSON.stringify(freshAgain));
console.log('requests that reached the data server in all:', JSON.stringify(hits));

check('the first visit got the copy made at build time, marked stale', [stale.n, stale.cache], [1, 'STALE']);
check('the next copy was made in the background, from one request', [fresh.n, midway], [2, { '/stamp/isr': 1 }]);
check('and is served as fresh', fresh.cache, 'HIT');
check('after the time was up again, the visit got the old copy first', [staleAgain.n, staleAgain.cache], [2, 'STALE']);
check('and the one after it the new one', [freshAgain.n, freshAgain.cache], [3, 'HIT']);
check('two requests in all, one per regeneration', hits, { '/stamp/isr': 2 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node isr.mjs

first visit, more than 2 seconds after the build: {"n":1,"cache":"STALE"}
a visit once the new copy was ready: {"n":2,"cache":"HIT"}
requests that reached the data server by then: {"/stamp/isr":1}
first visit after waiting 3.5 seconds: {"n":2,"cache":"STALE"}
a visit once the next copy was ready: {"n":3,"cache":"HIT"}
requests that reached the data server in all: {"/stamp/isr":2}
ok   the first visit got the copy made at build time, marked stale
ok   the next copy was made in the background, from one request
ok   and is served as fresh
ok   after the time was up again, the visit got the old copy first
ok   and the one after it the new one
ok   two requests in all, one per regeneration

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

A timer is a guess about when data goes out of date. When the code that changes the data knows, it can say so, which is the next lesson.

15 On-demand revalidation

Lesson 14 let data expire on a timer. When the code that changes the data is yours, it knows better than a timer: a form was submitted, a record was saved, a webhook arrived. Next.js has four functions, all from next/cache, for telling it that cached data or a cached page is out of date now: revalidateTag, revalidatePath, updateTag and refresh. Each panel changes something, then counts the requests that reached the data server.

By tag

A tag is a name you attach to cached data, so that one call can reach every page that used it. The page below fetches with next: { tags: ['stamps'] } and cache: 'force-cache' (lesson 14). A route handler (lesson 19) is the endpoint something outside the page would call after changing the data; it calls revalidateTag('stamps', 'max'). The second argument is required in Next.js 16; the vendored reference calls 'max' the recommended value and describes it as stale-while-revalidate: the entry is marked stale, the next visit is handed the old data, and fresh data is fetched in the background.

revalidateTag('stamps', 'max') from a route handler

ran at build time · next build + next start · Next 16.3.1

app/tags/list/page.js

import { connection } from 'next/server';

export default async function List() {
  await connection();
  const res = await fetch(`${process.env.DATA_URL}/stamp/tagged`, {
    cache: 'force-cache',
    next: { tags: ['stamps'] },
  });
  const stamp = await res.json();
  return (
    <main data-page="tags-list">
      <p>Tagged data from request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

app/api/revalidate/route.js

import { revalidatePath, revalidateTag } from 'next/cache';

// Only fixed values are accepted: an endpoint that revalidates whatever it is told to is an
// invitation to flush the cache from outside.
export async function POST(request) {
  const url = new URL(request.url);
  const tag = url.searchParams.get('tag');
  const path = url.searchParams.get('path');
  if (tag === 'stamps') {
    revalidateTag('stamps', 'max');
    return Response.json({ revalidated: 'stamps', how: 'max' });
  }
  if (tag === 'stamps-expire') {
    revalidateTag('stamps');
    return Response.json({ revalidated: 'stamps', how: 'one argument' });
  }
  if (path === '/tags/path') {
    revalidatePath('/tags/path');
    return Response.json({ revalidated: path });
  }
  return Response.json({ error: 'unknown tag or path' }, { status: 400 });
}

tag.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = (html) => Number(/data-n[^>]*>(\d+)</.exec(html)[1]);
const visit = async () => number(await (await fetch(base + '/tags/list')).text());
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const before = [await visit(), await visit()];
const res = await fetch(base + '/api/revalidate?tag=stamps', { method: 'POST' });
const after = [];
for (let i = 0; i < 6 && after.at(-1) !== 2; i++) { after.push(await visit()); await sleep(300); }
const hits = await (await fetch(data + '/_hits')).json();

console.log('two visits:', before.join(', '));
console.log('POST /api/revalidate?tag=stamps ->', res.status, JSON.stringify(await res.json()));
console.log('visits after it:', after.join(', '));
console.log('requests that reached the data server:', JSON.stringify(hits));

check('the tagged data was fetched once and reused', before, [1, 1]);
check('the handler answered', res.status, 200);
check('the first visit after the call still got the old data', after[0], 1);
check('the data was fetched again, once', [after.at(-1), hits], [2, { '/stamp/tagged': 2 }]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node tag.mjs

two visits: 1, 1
POST /api/revalidate?tag=stamps -> 200 {"revalidated":"stamps","how":"max"}
visits after it: 1, 2
requests that reached the data server: {"/stamp/tagged":2}
ok   the tagged data was fetched once and reused
ok   the handler answered
ok   the first visit after the call still got the old data
ok   the data was fetched again, once

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Calling the function did not make the next visit wait. That visit was answered from the old entry and started the new fetch, and the visit after it saw the result. The data server counted two requests in all: the first one, and the one the revalidation caused.

The older form, with one argument

Before Next.js 16, revalidateTag took only the tag. That form still runs, and its entry expires at once instead: the next visit has to wait for new data. The route handler has a branch that calls it that way so the panel can show what the server says about it.

revalidateTag('stamps') with one argument

ran at build time · next build + next start · Next 16.3.1

expire.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = (html) => Number(/data-n[^>]*>(\d+)</.exec(html)[1]);
const visit = async () => number(await (await fetch(base + '/tags/list')).text());

const before = await visit();
const res = await fetch(base + '/api/revalidate?tag=stamps-expire', { method: 'POST' });
const right = await visit();
const hits = await (await fetch(data + '/_hits')).json();

console.log('visit before:', before);
console.log('POST /api/revalidate?tag=stamps-expire ->', res.status, JSON.stringify(await res.json()));
console.log('the very next visit:', right);
console.log('requests that reached the data server:', JSON.stringify(hits));

check('the handler answered', res.status, 200);
check('the very next visit already had new data', right, before + 1);
check('after exactly one more request', hits, { '/stamp/tagged': 1 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node expire.mjs

visit before: 2
POST /api/revalidate?tag=stamps-expire -> 200 {"revalidated":"stamps","how":"one argument"}
the very next visit: 3
requests that reached the data server: {"/stamp/tagged":1}
ok   the handler answered
ok   the very next visit already had new data
ok   after exactly one more request

What next start printed meanwhile

"revalidateTag" without the second argument is now deprecated, add second argument of "max" or use "updateTag". See more info here: https://nextjs.org/docs/messages/revalidate-tag-single-arg

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The server printed a deprecation notice, and the very next visit already had new data, one request later. Use 'max' when a stale answer is acceptable for one visit, and updateTag below when it is not.

By path

revalidatePath names a page instead of a tag. The page below is built at build time (it reads nothing from the request), and the handler tells Next.js that this page's copy is out of date.

revalidatePath('/tags/path')

ran at build time · next build + next start · Next 16.3.1

app/tags/path/page.js

export default async function PathPage() {
  const res = await fetch(`${process.env.DATA_URL}/stamp/path`);
  const stamp = await res.json();
  return (
    <main data-page="tags-path">
      <p>Built from data request number <span data-n>{stamp.n}</span>.</p>
    </main>
  );
}

path.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = (html) => Number(/data-n[^>]*>(\d+)</.exec(html)[1]);
const visit = async () => number(await (await fetch(base + '/tags/path')).text());
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const before = [await visit(), await visit()];
const res = await fetch(base + '/api/revalidate?path=/tags/path', { method: 'POST' });
const after = [];
for (let i = 0; i < 6 && after.at(-1) !== 2; i++) { after.push(await visit()); await sleep(300); }
const hits = await (await fetch(data + '/_hits')).json();

console.log('two visits:', before.join(', '));
console.log('POST /api/revalidate?path=/tags/path ->', res.status, JSON.stringify(await res.json()));
console.log('visits after it:', after.join(', '));
console.log('requests that reached the data server:', JSON.stringify(hits));

check('the page built at build time was served', before, [1, 1]);
check('the handler answered', res.status, 200);
check('the first visit after the call already showed the page rendered again', after, [2]);
check('which took one request', hits, { '/stamp/path': 1 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node path.mjs

two visits: 1, 1
POST /api/revalidate?path=/tags/path -> 200 {"revalidated":"/tags/path"}
visits after it: 2
requests that reached the data server: {"/stamp/path":1}
ok   the page built at build time was served
ok   the handler answered
ok   the first visit after the call already showed the page rendered again
ok   which took one request

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

Here the first visit after the call already had the new page, with no stale answer in between. Which of the two behaviours you get is a property of the function that you call, as the tag panels showed, and not of the page.

From a Server Action: updateTag

updateTag is for the case where the person who changed the data must see their change on the next screen. It expires the tag at once, and it can only be called in a Server Action (lesson 17). A form is the way to run an action without a browser, so the panel reads the action's id from the page and posts it as a form would. Lesson 17 explains that field. The page has a button wired to an action that does nothing but call updateTag.

updateTag('action-stamps') in a Server Action

ran at build time · next build + next start · Next 16.3.1

app/tags/actions.js

'use server';
import { updateTag } from 'next/cache';

export async function expireStamp() {
  updateTag('action-stamps');
}

app/tags/action/page.js

import { connection } from 'next/server';
import { expireStamp } from '../actions.js';

export default async function ActionPage() {
  await connection();
  const res = await fetch(`${process.env.DATA_URL}/stamp/action`, {
    cache: 'force-cache',
    next: { tags: ['action-stamps'] },
  });
  const stamp = await res.json();
  return (
    <main data-page="tags-action">
      <p>Tagged data from request number <span data-n>{stamp.n}</span>.</p>
      <form action={expireStamp}>
        <button type="submit">Expire it</button>
      </form>
    </main>
  );
}

update-tag.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = (html) => Number(/data-n[^>]*>(\d+)</.exec(html)[1]);
const visit = async () => number(await (await fetch(base + '/tags/action')).text());

const page = await (await fetch(base + '/tags/action')).text();
const id = /name="(\$ACTION_ID_[0-9a-f]+)"/.exec(page)[1];
const form = new FormData();
form.append(id, '');
const posted = await fetch(base + '/tags/action', { method: 'POST', body: form, headers: { origin: base } });
const inResponse = number(await posted.text());
const next = [await visit(), await visit()];
const hits = await (await fetch(data + '/_hits')).json();

console.log('first visit:', number(page));
console.log('the response to the form post:', posted.status, 'data request number', inResponse);
console.log('the next two visits:', next.join(', '));
console.log('requests that reached the data server:', JSON.stringify(hits));

check('the page began with the first answer', number(page), 1);
check('the post succeeded', posted.status, 200);
check('the response to the post already held new data', inResponse, 2);
check('the visits after it showed data newer than that, and then kept it', next, [3, 3]);
check('three requests in all reached the data server', hits, { '/stamp/action': 3 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node update-tag.mjs

first visit: 1
the response to the form post: 200 data request number 2
the next two visits: 3, 3
requests that reached the data server: {"/stamp/action":3}
ok   the page began with the first answer
ok   the post succeeded
ok   the response to the post already held new data
ok   the visits after it showed data newer than that, and then kept it
ok   three requests in all reached the data server

The driver ran against next start -H 127.0.0.1 -p 0 of project app, right after its next build; BASE_URL is the address Next printed.

The response to the post held new data, so the page that comes back with an action does not show stale data. The visits after it showed a number higher again, and the data server counted three requests, so the re-render that shipped with the action's response and the first visit after it each asked for fresh data. By the next visit the entry was kept again. The vendored reference says updateTag is for read-your-own-writes; the numbers above are what that cost in requests here.

refresh

The fourth function, refresh, works on what the browser is showing and not on a cache, so there is nothing for a driver to count.

refresh() in a Server Action

shown, not run
'use server';
import { refresh } from 'next/cache';

export async function saveDraft(formData) {
  await save(formData);
  refresh();
}

refresh() tells the router in the browser to ask the server for the current page again. That is something a browser does after a Server Action, and no panel here has a browser. The vendored refresh.md describes it.

16 Cache Components and "use cache"

Lessons 14 and 15 are the model Next.js 16 has when cacheComponents is off. Switching it on is a different way to think about a page: Next.js builds as much of every page as it can ahead of time, and anything that cannot be built ahead has to say so, or the build fails. Caching becomes something you ask for with a directive, "use cache", on a component or a function. This lesson uses a second project, cc, which is the first project's smaller sibling with the flag on.

Switching it on

One line in next.config.mjs. The build says so in its log and the route table changes: a new symbol, ◐, for a page that is partly prerendered and partly dynamic. The home page has two parts. Stamp is marked "use cache" and asks the data server for a number; cacheLife('hours') says how long to keep the result and cacheTag names it for lesson 15's functions. Visit reads the time of the visit after await connection(), so it cannot be built ahead, and it is inside <Suspense> so that the rest of the page can be.

next build with cacheComponents: true

ran at build time · next build · Next 16.3.1

next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Turns on "use cache" and the prerender-everything-you-can model.
  cacheComponents: true,
};
export default nextConfig;

app/page.js

import { Suspense } from 'react';
import { connection } from 'next/server';
import { cacheLife, cacheTag } from 'next/cache';

async function Stamp() {
  'use cache';
  cacheLife('hours');
  cacheTag('cc-home');
  const res = await fetch(`${process.env.DATA_URL}/stamp/home`);
  const stamp = await res.json();
  return <p>Cached data from request number <span data-n>{stamp.n}</span>.</p>;
}

async function Visit() {
  await connection();
  return <p>This visit began at <span data-at>{Date.now()}</span>.</p>;
}

export default function Home() {
  return (
    <main data-page="cc-home">
      <Stamp />
      <Suspense fallback={<p data-fallback>Waiting for this visit...</p>}>
        <Visit />
      </Suspense>
    </main>
  );
}

app/args/page.js

import { Suspense } from 'react';
import { connection } from 'next/server';

async function lookup(id) {
  'use cache';
  const res = await fetch(`${process.env.DATA_URL}/stamp/${id}`);
  return res.json();
}

async function Lookups() {
  await connection();
  const first = await lookup('a');
  const again = await lookup('a');
  const other = await lookup('b');
  return (
    <p data-first={first.n} data-again={again.n} data-other={other.n}>
      a: {first.n}, a again: {again.n}, b: {other.n}
    </p>
  );
}

export default function Args() {
  return (
    <main data-page="cc-args">
      <Suspense fallback={<p>Looking up...</p>}>
        <Lookups />
      </Suspense>
    </main>
  );
}

at-build.mjs

import { readFileSync } from 'node:fs';
const hits = await (await fetch(process.env.DATA_URL + '/_hits')).json();
const manifest = JSON.parse(readFileSync(process.env.APP_DIR + '/.next/prerender-manifest.json', 'utf8'));
console.log('requests the build made:', JSON.stringify(hits));
console.log('prerender-manifest.json for /:', JSON.stringify(manifest.routes['/']));
console.log('prerender-manifest.json for /args:', JSON.stringify(manifest.routes['/args']));
check('the build fetched the cached component data once', hits['/stamp/home'], 1);
check('and nothing behind connection()', [hits['/stamp/a'], hits['/stamp/b']], [undefined, undefined]);
check('/args is partly static too, with no lifetime of its own', [manifest.routes['/args'].renderingMode, manifest.routes['/args'].initialRevalidateSeconds], ['PARTIALLY_STATIC', false]);
check('the home page is partly static, kept for an hour and allowed to expire after a day', [manifest.routes['/'].renderingMode, manifest.routes['/'].initialRevalidateSeconds, manifest.routes['/'].initialExpireSeconds], ['PARTIALLY_STATIC', 3600, 86400]);

What next build printed (Next 16.3.1), then the checks, when the build ran it

next build

▲ Next.js 16.3.1 (Turbopack)
✓ Running next.config.mjs took <t>
- Cache Components enabled

  Creating an optimized production build ...
✓ Compiled successfully in <t>
  Running TypeScript ...
  Finished TypeScript in <t> ...
  Collecting page data using <n> workers ...
✓ Generating static pages using <n> workers (5/5) in <t>
  Finalizing page optimization ...

Route (app)      Revalidate  Expire
┌ ◐ /                    1h      1d
├ ○ /_not-found
├ ƒ /api/expire
└ ◐ /args


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

ok   the log shows "- Cache Components enabled"
ok   the log shows "◐  (Partial Prerender)"
ok   the log shows "1h      1d"
requests the build made: {"/stamp/home":1}
prerender-manifest.json for /: {"renderingMode":"PARTIALLY_STATIC","routeType":"page","response":"initial","compute":"resuming","htmlSize":1084,"experimentalPPR":true,"experimentalBypassFor":[{"type":"header","key":"next-action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"},{"type":"header","key":"user-agent","value":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight"}],"initialRevalidateSeconds":3600,"initialExpireSeconds":86400,"srcRoute":"/","dataRoute":"/index.rsc","allowHeader":["host","x-matched-path","x-prerender-revalidate","x-prerender-revalidate-if-generated","x-next-revalidated-tags","x-next-revalidate-tag-token"]}
prerender-manifest.json for /args: {"renderingMode":"PARTIALLY_STATIC","routeType":"page","response":"initial","compute":"resuming","htmlSize":1000,"experimentalPPR":true,"experimentalBypassFor":[{"type":"header","key":"next-action"},{"type":"header","key":"content-type","value":"multipart/form-data;.*"},{"type":"header","key":"user-agent","value":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight"}],"initialRevalidateSeconds":false,"srcRoute":"/args","dataRoute":"/args.rsc","allowHeader":["host","x-matched-path","x-prerender-revalidate","x-prerender-revalidate-if-generated","x-next-revalidated-tags","x-next-revalidate-tag-token"]}
ok   the build fetched the cached component data once
ok   and nothing behind connection()
ok   /args is partly static too, with no lifetime of its own
ok   the home page is partly static, kept for an hour and allowed to expire after a day

The check script ran after the build, with APP_DIR set to the project, and read the files in .next.

The route table gives / the symbol ◐, and its Revalidate and Expire columns read 1h and 1d, the lifetimes of the hours profile. The manifest holds the same numbers in seconds. /args is ◐ too, with no lifetime of its own: everything it reads is behind connection(), so its shell is a static frame.

A cached part and a fresh part on one page

A cached component and a fresh one on one page

ran at build time · next build + next start · Next 16.3.1

home.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const get = async () => {
  const html = await (await fetch(base + '/')).text();
  return { n: Number(/data-n[^>]*>(\d+)</.exec(html)[1]), at: Number(/data-at[^>]*>(\d+)</.exec(html)[1]), html };
};
const visits = [await get(), await get(), await get()];
const hits = await (await fetch(data + '/_hits')).json();
console.log('data request number in three visits:', visits.map((v) => v.n).join(', '));
console.log('the time of the visit was different each time:', new Set(visits.map((v) => v.at)).size === 3);
console.log('requests that reached the data server:', JSON.stringify(hits));
check('the cached part was the same in every visit', visits.map((v) => v.n), [1, 1, 1]);
check('the part behind connection() was rendered for each visit', new Set(visits.map((v) => v.at)).size, 3);
check('no visit asked the data server', hits, {});

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node home.mjs

data request number in three visits: 1, 1, 1
the time of the visit was different each time: true
requests that reached the data server: {}
ok   the cached part was the same in every visit
ok   the part behind connection() was rendered for each visit
ok   no visit asked the data server

The driver ran against next start -H 127.0.0.1 -p 0 of project cc, right after its next build; BASE_URL is the address Next printed.

The cached component gave the same answer every time and did not ask the data server again, since its result was made during the build. The part behind connection() was rendered for each visit, which three different times show. Both are on the same page, so one visit gets both a stored part and a fresh one.

The arguments are the cache key

A function marked "use cache" can take arguments, and Next.js keeps one entry for each different set of them. lookup below asks the data server for /stamp/<id>. The page calls it with 'a', with 'a' again, and with 'b', three times over the three visits.

The arguments are the cache key

ran at build time · next build + next start · Next 16.3.1

args.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const get = async () => {
  const html = await (await fetch(base + '/args')).text();
  const pick = (name) => Number(new RegExp('data-' + name + '="(\\d+)"').exec(html)[1]);
  return [pick('first'), pick('again'), pick('other')];
};
const visits = [await get(), await get(), await get()];
const hits = await (await fetch(data + '/_hits')).json();
console.log('lookup("a"), lookup("a"), lookup("b") in three visits:', JSON.stringify(visits));
console.log('requests that reached the data server:', JSON.stringify(hits));
check('the same argument gave the same answer, every time', visits.map((v) => v[0] === v[1]), [true, true, true]);
check('each argument made one request in all three visits', hits, { '/stamp/a': 1, '/stamp/b': 1 });

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node args.mjs

lookup("a"), lookup("a"), lookup("b") in three visits: [[1,1,1],[1,1,1],[1,1,1]]
requests that reached the data server: {"/stamp/a":1,"/stamp/b":1}
ok   the same argument gave the same answer, every time
ok   each argument made one request in all three visits

The driver ran against next start -H 127.0.0.1 -p 0 of project cc, right after its next build; BASE_URL is the address Next printed.

Nine calls, three visits and two different arguments made two requests: one for each argument. The second call with 'a' and every visit after the first were answered from the stored entries.

Invalidating by tag

The tag set by cacheTag('cc-home') is the same kind of tag as in lesson 15, and revalidateTag reaches it. The handler below is the whole mechanism.

cacheTag('cc-home') and revalidateTag

ran at build time · next build + next start · Next 16.3.1

app/api/expire/route.js

import { revalidateTag } from 'next/cache';

export async function POST() {
  revalidateTag('cc-home', 'max');
  return Response.json({ revalidated: 'cc-home' });
}

tag.mjs

const base = process.env.BASE_URL;
const data = process.env.DATA_URL;
const number = async () => Number(/data-n[^>]*>(\d+)</.exec(await (await fetch(base + '/')).text())[1]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = await number();
const res = await fetch(base + '/api/expire', { method: 'POST' });
const after = [];
for (let i = 0; i < 6 && after.at(-1) !== 2; i++) { after.push(await number()); await sleep(300); }
const hits = await (await fetch(data + '/_hits')).json();
console.log('before:', before, ' POST /api/expire ->', res.status);
console.log('visits after it:', after.join(', '));
console.log('requests that reached the data server:', JSON.stringify(hits));
check('the handler answered', res.status, 200);
check('the first visit after the call still got the old data', after[0], 1);
check('the cached component ran again, once', [after.at(-1), hits], [2, { '/stamp/home': 1 }]);

What the driver printed (Next 16.3.1, next build + next start), when the build ran it

node tag.mjs

before: 1  POST /api/expire -> 200
visits after it: 1, 2
requests that reached the data server: {"/stamp/home":1}
ok   the handler answered
ok   the first visit after the call still got the old data
ok   the cached component ran again, once

The driver ran against next start -H 127.0.0.1 -p 0 of project cc, right after its next build; BASE_URL is the address Next printed.

Same behaviour as lesson 15's 'max': the visit that follows is answered from the old entry, and the one after it sees the new data.

Switching a project that already exists to this model changes how every page is classified, so it is a migration and not a setting. The vendored guide for it is node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.md. This course keeps the other projects on the default model, and the lessons that follow say which model they are in.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.