Emmanuel Akinfulubi

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.

Unit tests of the plain files, run with node:test

ran at build time · node:test + next start · Next 16.3.1

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.

tools/test-reporter.mjs

shown, not run: the course's own tool
/* 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 against next start

ran at build time · node:test + next start · Next 16.3.1

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.

Reading what the guide says about async components

ran at build time · node:test + next start · Next 16.3.1

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.

An end-to-end test with Playwright

shown, not run
// 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.

next build of the app project, and the table checked against .next

ran at build time · next build · Next 16.3.1

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 with generateStaticParams: 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.

A build that fails, whole log

fails on purpose · next build · Next 16.3.1

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.

What the build leaves for next start

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

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.

The Cache-Control a CDN would see

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

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:

output: standalone and a two-stage Dockerfile

shown, not run
// 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.

Could the app project be a static export?

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

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.

Newsletter

New writing, when it lands.

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