Quality and shipping all 3 written
Testing without extra packages, reading the output of next build, and what deployment needs.
28 Testing
A Next.js app has pieces that are easy to test and pieces that are not,
and a lot of the skill is arranging the code so the first kind is most of it. The Node.js course
teaches the built-in test runner, node:test,
and this lesson uses nothing else: no test package is in the vendored tree, and none is needed for
the two kinds of test that give the most for the least. The React course covers
testing components in a browser-like environment, which
needs packages outside this course.
Pull the logic out, then test it as plain functions
A Server Action is hard to test directly: it reads cookies, redirects by throwing, and only
works inside a request. So the course's actions are thin. The rules live in plain functions in plain
files, validate.js for form input and session-core.js for passwords and
tokens, that import nothing from Next.js, and the action is the glue. A test imports those files
and calls the functions, with no server running. This is the cheapest, fastest and most useful kind
of test, and the reason the sessions lesson put its crypto in a separate file.
The panel below is a terminal panel: a file of node:test tests, run by the build
with node. node:test's own reporters print how long each test took, and a page
built from those could never be reproduced exactly, so the course gives the runner a reporter of
its own, shown after the panel. If a test fails, the line says FAIL and the build
stops, as it does for every panel.
app/_lib/validate.js
// Validation by hand: a pure function, so it can be tested without Next.js (lesson 28).
export function validateSignup({ name, email, password }) {
const errors = {};
if (name.trim().length === 0) errors.name = 'Enter your name.';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Enter an email address like you@example.com.';
if (password.length < 8) errors.password = 'Use at least 8 characters.';
return errors;
}
unit-tests.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validateSignup } from '../app/app/_lib/validate.js';
import { hashPassword, readSession, signSession, verifyPassword } from '../app/app/_lib/session-core.js';
test('validateSignup: an empty form has an error for every field', () => {
assert.deepEqual(Object.keys(validateSignup({ name: '', email: '', password: '' })).sort(), ['email', 'name', 'password']);
});
test('validateSignup: a good form has no errors', () => {
assert.deepEqual(validateSignup({ name: 'Ada', email: 'ada@example.com', password: 'long enough' }), {});
});
test('validateSignup: a short password is told the rule', () => {
assert.equal(validateSignup({ name: 'Ada', email: 'ada@example.com', password: 'short' }).password, 'Use at least 8 characters.');
});
test('hashPassword: the same password hashes differently each time', () => {
assert.notEqual(hashPassword('correct horse'), hashPassword('correct horse'));
});
test('verifyPassword: accepts the right password and rejects a wrong one', () => {
const stored = hashPassword('correct horse');
assert.equal(verifyPassword('correct horse', stored), true);
assert.equal(verifyPassword('wrong horse', stored), false);
});
test('readSession: a token signed with the secret reads back', () => {
const token = signSession({ sub: 3, exp: 2_000 }, 'secret');
assert.equal(readSession(token, 'secret', 1_000).sub, 3);
});
test('readSession: a token with a changed payload is refused', () => {
const [, signature] = signSession({ sub: 3, exp: 2_000 }, 'secret').split('.');
const edited = Buffer.from(JSON.stringify({ sub: 1, exp: 2_000 })).toString('base64url');
assert.equal(readSession(edited + '.' + signature, 'secret', 1_000), null);
});
test('readSession: an expired token is refused', () => {
assert.equal(readSession(signSession({ sub: 3, exp: 2_000 }, 'secret'), 'secret', 3_000), null);
});
What the tests printed (Next 16.3.1, node:test + next start), when the build ran them
node unit-tests.mjs
ok validateSignup: an empty form has an error for every field ok validateSignup: a good form has no errors ok validateSignup: a short password is told the rule ok hashPassword: the same password hashes differently each time ok verifyPassword: accepts the right password and rejects a wrong one ok readSession: a token signed with the secret reads back ok readSession: a token with a changed payload is refused ok readSession: an expired token is refused
The tests ran under node 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 reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.
Each line is one test. They ran in one node process, without Next.js; the files they
import are the files Next.js compiles into the app. The imports look odd:
../app/app/_lib/..., because the course puts the tests in a folder next to the project
and names the project's folder app; in your project they would be
../app/_lib/validate.js or an alias.
The reporter is below. It is the only thing the course changed about how node:test
reports: a function that receives the events and prints a line for each test that passed or failed. Run
node --test yourself and you get Node's usual report, with the durations.
/* The reporter the course gives node:test in a terminal panel.
node:test's own reporters print how long every test took, and the durations differ on
every run, so a page built from them could never be reproduced byte for byte. This one
prints one line per test, in the same form as every other check in the course:
ok <test name>
FAIL <test name> <first line of the error>
Suites ("describe") are not printed; only the tests inside them are. It is passed to
node with --test-reporter, and nothing else about node:test is changed. */
export default async function* reporter(source) {
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue;
if (event.data.details && event.data.details.type === 'suite') continue;
if (event.type === 'test:pass') {
yield `ok ${event.data.name}\n`;
} else {
const error = event.data.details && event.data.details.error;
yield `FAIL ${event.data.name} ${String((error && error.message) || 'failed').split('\n')[0]}\n`;
}
}
}
this is the reporter the build passes to node:test for every terminal panel. It runs under the same sandbox as the tests.
Test the running app over HTTP
The other cheap kind of test starts the app and talks to it like a browser would, with
fetch. It checks what a user could see: status codes, redirects, the HTML, the JSON. It
cannot see inside a component, but it exercises everything together, including the proxy and the
route table. In a real project a test would start next start itself in a
before hook; here the build already started it, and the address is in
BASE_URL, exactly as for the other panels.
http-tests.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
const base = process.env.BASE_URL;
const get = (path) => fetch(base + path, { redirect: 'manual' });
test('GET /api/hello answers 200 with the JSON the handler returns', async () => {
const res = await get('/api/hello');
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type'), /^application\/json/);
assert.deepEqual(await res.json(), { hello: 'world' });
});
test('GET /about is an HTML page that names its own file', async () => {
const res = await get('/about');
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type'), /^text\/html/);
assert.match(await res.text(), /app\/about\/page\.js/);
});
test('an address with no page answers 404', async () => {
assert.equal((await get('/no-such-page')).status, 404);
});
test('GET /api/me without a session is a 401 with a message, not a redirect', async () => {
const res = await get('/api/me');
assert.equal(res.status, 401);
assert.deepEqual(await res.json(), { error: 'not signed in' });
});
test('GET /account without a cookie redirects to /login', async () => {
const res = await get('/account');
assert.equal(res.status, 307);
assert.equal(new URL(res.headers.get('location'), base).pathname, '/login');
});
test('GET /robots.txt points at the sitemap', async () => {
assert.match(await (await get('/robots.txt')).text(), /Sitemap: /);
});
What the tests printed (Next 16.3.1, node:test + next start), when the build ran them
node http-tests.mjs
ok GET /api/hello answers 200 with the JSON the handler returns ok GET /about is an HTML page that names its own file ok an address with no page answers 404 ok GET /api/me without a session is a 401 with a message, not a redirect ok GET /account without a cookie redirects to /login ok GET /robots.txt points at the sitemap
The tests ran under node 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 reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.
Six requests, six answers, none of which needed a browser. A test like this is also the place to put a regression: when a bug is found, write the request that shows it before fixing it.
What is hard to test, and what this course does not run
Components that fetch data on the server (async server components) cannot be rendered by
the usual component-testing tools. The vendored testing guide says so and recommends end-to-end
tests for them. The next test reads the guide to confirm the sentence is there, so that the advice
here is the guide's and not mine.
guide-tests.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const guide = readFileSync(process.env.APP_DIR + '/node_modules/next/dist/docs/01-app/02-guides/testing/index.md', 'utf8');
test('the vendored testing guide says async server components are not fully supported by unit-test tools', () => {
assert.ok(guide.includes('some tools do not fully support them'));
});
test('and that it recommends end-to-end tests for them', () => {
assert.ok(guide.includes('we recommend using **End-to-End Testing** over **Unit Testing**'));
});
What the tests printed (Next 16.3.1, node:test + next start), when the build ran them
node guide-tests.mjs
ok the vendored testing guide says async server components are not fully supported by unit-test tools ok and that it recommends end-to-end tests for them
The tests ran under node 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 reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.
End-to-end tests drive a real browser. The tools the guide covers are below; none of them is vendored here, so the lesson can show them and cannot run them.
// e2e/home.spec.js (Playwright)
import { test, expect } from '@playwright/test';
test('the home page greets the reader', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Hello from the server' })).toBeVisible();
});
Playwright, Vitest, Jest, Cypress and React Testing Library are packages that are not in the vendored tree, and nothing in this course may install one. The vendored guides under next/dist/docs show how to set each up. A browser also needs an application that can open a window, which a panel here cannot.
29 Reading next build output
next build prints a lot, most of it once, and a person who
can read it fast knows at a glance whether a page ended up static or dynamic, whether it will
refresh, and what broke. This lesson takes the log of the app project apart, and checks every
reading against the files the build wrote in .next, so that the table is not taken on
trust.
The log, top to bottom
The first lines say what is building and with what: the version and the bundler,
which .env files were read (lesson 22), and that the
configuration file ran. Then the work, in a fixed order: compile everything
(Compiled successfully), a TypeScript step that appears here though the project is plain
JavaScript, collect page data, generate the static pages, finalise. Last comes the route table. The
panel below shows the whole log, then a script reads it back, together with the build's own files,
and checks the order and what each symbol means.
table.mjs
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const root = process.env.APP_DIR;
const log = readFileSync(join(dirname(root), 'build.out'), 'utf8');
const rows = [...log.matchAll(/^[┌├└│](?:\s[├└│])*\s+([○●ƒ◐])\s+(\S+)(.*)$/gm)].map((m) => ({ symbol: m[1], path: m[2], rest: m[3].trim() }));
const manifest = JSON.parse(readFileSync(root + '/.next/prerender-manifest.json', 'utf8'));
const prerendered = new Set(Object.keys(manifest.routes));
const pathsWith = (symbol) => rows.filter((row) => row.symbol === symbol).map((row) => row.path);
const [fixed, generated, dynamic] = [pathsWith('○'), pathsWith('●'), pathsWith('ƒ')];
const listed = new Set(rows.map((row) => row.path));
const at = (text) => log.indexOf(text);
const isr = rows.find((row) => row.path === '/cache/isr');
const bin = readFileSync(root + '/node_modules/next/dist/bin/next', 'utf8');
const files = (route) => ['html', 'rsc', 'meta'].map((ext) => existsSync(root + '/.next/server/app' + route + '.' + ext));
console.log('table rows:', rows.length, ' ○', fixed.length, ' ●', generated.length, ' ƒ', dynamic.length);
console.log('prerendered routes in the manifest:', prerendered.size);
console.log('/cache/isr row:', isr.rest.split(/\s+/).join(' '));
check('the phases come in this order', [at('Compiled successfully'), at('Collecting page data'), at('Generating static pages'), at('Finalizing page optimization'), at('Route (app)')].every((n, i, all) => n > -1 && (i === 0 || n > all[i - 1])), true);
check('every ○ row is a path the build prerendered', fixed.every((path) => prerendered.has(path)), true);
check('and so is every ● row', generated.every((path) => prerendered.has(path)), true);
check('no ƒ row is', dynamic.some((path) => prerendered.has(path)), false);
check('this project has no ◐ row', pathsWith('◐').length, 0);
check('the manifest holds one path the table does not list, the global error page', [...prerendered].filter((path) => !listed.has(path)), ['/_global-error']);
check('the one row with a lifetime shows it, and the manifest has the same numbers', [isr.rest.split(/\s+/), manifest.routes['/cache/isr'].initialRevalidateSeconds, manifest.routes['/cache/isr'].initialExpireSeconds], [['2s', '1y'], 2, 31536000]);
check('there is no size column in this version', /First Load JS/.test(log), false);
check('the proxy is listed after the routes, and the build wrote its manifest', [/^ƒ Proxy \(Middleware\)$/m.test(log), existsSync(root + '/.next/server/middleware-manifest.json')], [true, true]);
check('a prerendered route has an HTML file, a payload file and a metadata file', files('/about'), [true, true, true]);
check('a dynamic route has no HTML file', existsSync(root + '/.next/server/app/account.html'), false);
check('next build documents the flags for more detail', ['--debug', '--debug-prerender', '--profile', '--experimental-analyze', '--webpack', '--turbopack'].map((flag) => bin.includes(flag + "'")), [true, true, true, true, true, true]);
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 "Compiled successfully" ok the log shows "Collecting page data" ok the log shows "Generating static pages" ok the log shows "Finalizing page optimization" ok the log shows "Route (app)" ok the log shows "Revalidate Expire" ok the log shows "○ (Static)" ok the log shows "● (SSG)" ok the log shows "ƒ (Dynamic)" ok the log shows "ƒ Proxy (Middleware)" ok the log shows "○ /cache/isr" ok the log shows "● /books/dracula" ok the log shows "ƒ /api/hello" table rows: 68 ○ 27 ● 3 ƒ 38 prerendered routes in the manifest: 31 /cache/isr row: 2s 1y ok the phases come in this order ok every ○ row is a path the build prerendered ok and so is every ● row ok no ƒ row is ok this project has no ◐ row ok the manifest holds one path the table does not list, the global error page ok the one row with a lifetime shows it, and the manifest has the same numbers ok there is no size column in this version ok the proxy is listed after the routes, and the build wrote its manifest ok a prerendered route has an HTML file, a payload file and a metadata file ok a dynamic route has no HTML file ok next build documents the flags for more detail
The check script ran after the build, with APP_DIR set to the project, and read the files in .next.
The route table
The table lists every route that the build found. The columns are the route's path and, only
when some route has one, Revalidate and Expire. This version prints no
sizes; the panel checked that. The symbol at the left says how the route is served:
○static: rendered once at build time, and a file for it exists in.next/server/app. The panel confirmed that each one is in the prerender manifest.●static withgenerateStaticParams: one prerendered file per parameter value, listed under the path with the pattern (lesson 5).ƒdynamic: rendered on demand. None of these is in the manifest, and a dynamic page has no HTML file.◐partial prerender, which appears when Cache Components is on (lesson 16, a different project). It is not in this table, because this project does not turn that on.
The two extra columns are filled for a route that refreshes by time: /cache/isr says
2s and 1y. The first is the revalidate interval that lesson 14 measured in
requests; the second is the expiry, which the manifest keeps as initialExpireSeconds. Last comes ƒ Proxy (Middleware): the proxy is not a route, so it gets a
line of its own (lesson 20). The panel's last checks show that the
build left middleware-manifest.json for it. The table also leaves out
/_global-error, a page the build adds for errors that escape every other boundary; the
manifest has it, and the panel found it was the only difference.
If you want more, next build has options. The panel confirmed that
--debug (more verbose output), --debug-prerender (to debug a prerender
failure; the help text says not for production), --profile,
--experimental-analyze (bundle analysis, Turbopack only) and the bundler choices
--webpack and --turbopack are all in the vendored program's option list. This
course ran none of them, since each is another full build of the project.
When the build fails
A build that cannot finish prints the error and exits with status 1. Anything that runs your build, a deploy script or a CI job, reads that
status. The panel below fails on purpose: a page that exports dynamic in a project that
turned on Cache Components. The badge says fails on purpose, and the course checks that the
exit status was 1 and that each line of the anatomy is present.
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
cacheComponents: true,
};
export default nextConfig;
app/page.js
export const dynamic = 'force-dynamic';
export default function Home() {
return <h1>Home</h1>;
}
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 ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./app/page.js:1:14
Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 1 | export const dynamic = 'force-dynamic';
| ^^^^^^^
2 | export default function Home() {
3 | return <h1>Home</h1>;
4 | }
Ecmascript file had an error
at <unknown> (./app/page.js:1:14)
ok the log shows "> Build error occurred"
ok the log shows "Error: Turbopack build failed with 1 error:"
ok the log shows "./app/page.js:1:14"
ok the log shows "Route segment config \"dynamic\" is not compatible"
ok the log shows "Ecmascript file had an error"
Read it from the top. Build error occurred says the build stopped.
Turbopack build failed with 1 error counts the errors it collected; with several
mistakes the number is larger, and the catalogue has a build
with more than one. Then each error has the same shape: a header with the file, line and column
(./app/page.js:1:14), the message, a code frame with the offending line marked by
> and a row of ^ under the exact characters, and a closing line that
names the kind of failure. The last line, which starts with at, repeats the position;
the header, the message and the frame are what to read. The other kind of failure, an error while
prerendering a page, looks different: it has no header with a position, and it names the
route. The catalogue shows one.
30 Deployment concepts
Deploying a Next.js app means answering one question: what has to exist on the machine that serves it, and what has to be in front of it? This course has no network and no hosting account, so it deploys nothing. What it can do is look at the build it made, and at what the server says about it, and check what the vendored guides claim against that.
What next start needs
The simplest deployment is the one you have been running: build, copy the project to a machine
with Node.js, install the dependencies, run next start
(the Node.js course covers running a Node
process in production: ports, restarts, logs). The vendored deployment guide calls this a Node.js
server and says it supports every feature. The build leaves a file that lists what the server reads
before it starts, and the panel opens it.
needs.mjs
import { existsSync, readFileSync } from 'node:fs';
const root = process.env.APP_DIR;
const required = JSON.parse(readFileSync(root + '/.next/required-server-files.json', 'utf8'));
const guide = readFileSync(root + '/node_modules/next/dist/docs/01-app/01-getting-started/17-deploying.md', 'utf8');
const output = readFileSync(root + '/node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/output.md', 'utf8');
console.log('keys:', Object.keys(required).join(', '));
console.log('files it names:', required.files.length, ' all under .next/:', required.files.every((file) => file.startsWith('.next/')));
console.log('records the directory the build ran in:', required.appDir === root);
console.log('output setting:', required.config.output, ' distDir:', required.config.distDir);
console.log('a .next/standalone folder:', existsSync(root + '/.next/standalone'));
check('the build wrote a file of what the server needs, with these keys', Object.keys(required), ['version', 'config', 'appDir', 'relativeAppDir', 'files', 'ignore']);
check('every file it names is inside .next, and the build id is one of them', [required.files.every((file) => file.startsWith('.next/')), required.files.includes('.next/BUILD_ID')], [true, true]);
check('it records the absolute path of the directory the build ran in', required.appDir === root, true);
check('this build used the default output and the default folder', [required.config.output, required.config.distDir], [undefined, '.next']);
check('so it made no standalone folder', existsSync(root + '/.next/standalone'), false);
check('the vendored guide names a Node.js server and Docker, says the first supports every feature, and lists verified adapters', [guide.includes('[Node.js server]'), guide.includes('[Docker container]'), guide.includes('Node.js deployments support all Next.js features'), guide.includes('Verified Adapters')], [true, true, true, true]);
check('the vendored output guide describes a standalone folder whose static files are left to a CDN', ['.next/standalone', 'server.js', 'ideally be handled by a CDN'].map((phrase) => output.includes(phrase)), [true, true, true]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node needs.mjs
keys: version, config, appDir, relativeAppDir, files, ignore files it names: 17 all under .next/: true records the directory the build ran in: true output setting: undefined distDir: .next a .next/standalone folder: false ok the build wrote a file of what the server needs, with these keys ok every file it names is inside .next, and the build id is one of them ok it records the absolute path of the directory the build ran in ok this build used the default output and the default folder ok so it made no standalone folder ok the vendored guide names a Node.js server and Docker, says the first supports every feature, and lists verified adapters ok the vendored output guide describes a standalone folder whose static files are left to a CDN
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.
Everything the file names is in .next, but the server also needs the project's
package.json and its installed node_modules (the framework is a library you
run), the public folder for the files served as they are, and the environment
variables of lesson 22. The file also records the absolute path the build ran in, which matters in
practice: build in the place you will run, or use the folder described further down.
What goes in front: caching headers
Real deployments put a CDN or a reverse proxy in front of the server. Next.js tells them what they
may keep by the headers it sends, and the vendored CDN guide says that it sets
Cache-Control by the rendering strategy of each route. The panel reads four of them from
the running server. The files with a hash in their name never change, so they are cacheable for a
year and marked immutable; a prerendered page is cacheable by shared caches
(s-maxage is the shared-cache lifetime, a browser ignores it); a page rendered per request
says not to be stored; a file from public/ may be kept but must be checked again each
time.
headers.mjs
import { readdirSync, readFileSync } from 'node:fs';
const base = process.env.BASE_URL;
const root = process.env.APP_DIR;
const chunk = readdirSync(root + '/.next/static/chunks').find((name) => name.endsWith('.js'));
const cacheControl = async (path) => (await fetch(base + path)).headers.get('cache-control');
const guide = readFileSync(root + '/node_modules/next/dist/docs/01-app/02-guides/cdn-caching.md', 'utf8');
const seen = {
'a hashed script': await cacheControl('/_next/static/chunks/' + chunk),
'a static page': await cacheControl('/about'),
'a dynamic page': await cacheControl('/clock/dynamic'),
'a file in public/': await cacheControl('/hello.txt'),
};
for (const [what, value] of Object.entries(seen)) console.log(what.padEnd(18), value);
check('a file with a hash in its name is cacheable for a year and immutable', seen['a hashed script'], 'public, max-age=31536000, immutable');
check('a prerendered page is cacheable by shared caches for a year', seen['a static page'], 's-maxage=31536000');
check('a page rendered per request must not be stored', seen['a dynamic page'], 'private, no-cache, no-store, max-age=0, must-revalidate');
check('a file in public/ may be kept but is checked again', seen['a file in public/'], 'public, max-age=0');
check('the vendored CDN guide says the headers follow the rendering strategy', guide.includes('based on the rendering strategy of each route'), true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node headers.mjs
a hashed script public, max-age=31536000, immutable a static page s-maxage=31536000 a dynamic page private, no-cache, no-store, max-age=0, must-revalidate a file in public/ public, max-age=0 ok a file with a hash in its name is cacheable for a year and immutable ok a prerendered page is cacheable by shared caches for a year ok a page rendered per request must not be stored ok a file in public/ may be kept but is checked again ok the vendored CDN guide says the headers follow the rendering strategy
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 static page's lifetime is a year, but the content can still change: on the next build, or when you revalidate it (lessons 14 and 15). A CDN has to be told to let go of old copies, which is why guides about deploying to a platform spend so much time on cache invalidation. That part depends on the platform, and is out of reach here.
Smaller to ship: standalone output
A node_modules folder is large. Setting output: 'standalone' in
next.config.mjs makes the build copy only the files the server needs into
.next/standalone, with a small server.js to start it, so the folder can
be deployed without installing anything. The vendored output guide says that, and also that
public and .next/static are not copied by default, because a CDN is expected to
serve them (the first panel found those phrases). The app project has no such folder, because it did not
ask for one.
This is the usual shape of a Docker image:
// next.config.mjs
export default {
output: 'standalone',
};
# Dockerfile (two stages: build, then run)
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
it builds a container image, which needs Docker and a package registry, and this course has neither. Every flag and path is from the vendored output guide, but the file itself was not built. The image names and Node version are examples.
No server at all: static export
If every page can be made at build time, output: 'export' makes a folder of plain
files that any static host can serve. The price is every feature that needs a server. The vendored
guide lists them, and since this course has the app project, it can ask whether that project
could be exported. It cannot, and the panel says why.
export.mjs
import { existsSync, readFileSync } from 'node:fs';
const root = process.env.APP_DIR;
const guide = readFileSync(root + '/node_modules/next/dist/docs/01-app/02-guides/static-exports.md', 'utf8');
const section = guide.slice(guide.indexOf('## Unsupported Features'));
const listed = section.split('\n').slice(0, 24).filter((line) => line.startsWith('- ')).map((line) => /\[([^\]]+)\]/.exec(line)[1]);
const config = readFileSync(root + '/next.config.mjs', 'utf8');
const cookieHandler = readFileSync(root + '/app/api/cookie/route.js', 'utf8');
const action = readFileSync(root + '/app/account/actions.js', 'utf8');
console.log('the guide lists as unsupported:');
for (const feature of listed) console.log(' -', feature);
console.log('this project has: proxy.js', existsSync(root + '/proxy.js'), '| redirects()', config.includes('redirects'), '| headers()', config.includes('headers'), '| a handler reading cookies', cookieHandler.includes('cookies'), "| a 'use server' file", action.includes('use server'));
check('the guide lists cookies, rewrites, redirects, headers, the proxy and Server Actions as unsupported', ['Cookies', 'Rewrites', 'Redirects', 'Headers', 'Proxy', 'Server Actions'].map((feature) => listed.includes(feature)), [true, true, true, true, true, true]);
check('this project uses several of them: a proxy, redirects, headers, cookies and actions', [existsSync(root + '/proxy.js'), config.includes('redirects'), config.includes('headers'), cookieHandler.includes('cookies'), action.includes('use server')], [true, true, true, true, true]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node export.mjs
the guide lists as unsupported: - Dynamic Routes - Dynamic Routes - Route Handlers - Cookies - Rewrites - Redirects - Headers - Proxy - Incremental Static Regeneration - Image Optimization - Draft Mode - Server Actions - Intercepting Routes this project has: proxy.js true | redirects() true | headers() true | a handler reading cookies true | a 'use server' file true ok the guide lists cookies, rewrites, redirects, headers, the proxy and Server Actions as unsupported ok this project uses several of them: a proxy, redirects, headers, cookies and actions
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 guide's rule is that these features need a server, and the project uses five of them, so an export of it would leave them out or fail. The panel did not run an export to see which. A site made only of static pages (a blog, documentation) is the case it is for.
Platforms and adapters
Hosting platforms usually run Next.js their own way, through an adapter: a package that takes the build and fits it to the platform's functions and cache. The deployment guide lists the platforms and says which adapters the Next.js team tests (the first panel found its table). They are outside this course, because choosing one means picking a vendor, and nothing here may reach one. What carries over is what you read in this lesson: the build is a set of files, the server is a Node.js program, the headers say how long to keep each response, and the environment variables (lesson 22) are the part of the configuration that differs between machines.