Configuration and assets all 4 written
Environment variables and next.config.mjs, metadata, images and fonts, all served from this machine.
22 Environment variables and configuration
The same code runs on your machine, in a test and on a server, and
some of what it needs differs between them: the address of a database, a key, a name. Those go in
environment variables, which the Node.js course
reads through process.env. Next.js reads them from files, decides which ones the
browser may see, and decides when a value is read: at build time or on each request. Then
next.config.mjs holds the settings that are not secrets.
The files, and what reaches the browser
Next.js loads .env, .env.local and, for next build
and next start, .env.production, and prints the ones it found in the
build log. In this repository files whose names start with .env are
ignored by git, so the project keeps them as dot-env, dot-env.production
and dot-env.local, and the course writes them under their real names before the
build. The panel shows them under the names they have here.
A variable is available to server code only, unless its name starts with
NEXT_PUBLIC_. Those are replaced with their values in the code that is sent to
browsers, when the build runs. The component below reads NEXT_PUBLIC_SHELF_NAME.
The check script searches every JavaScript file the build wrote for browsers, for that value
and for the value of APP_GREETING, a variable without the prefix.
dot-env
# Read by next build, next start and next dev.
APP_GREETING=hello from .env
SHARED_VALUE=from .env
LEVEL=from .env
NEXT_PUBLIC_SHELF_NAME=Shelf (from .env)
dot-env.production
# Read by next build and next start only, and wins over .env.
SHARED_VALUE=from .env.production
LEVEL=from .env.production
dot-env.local
# Read in every mode except test, and wins over .env.production.
LEVEL=from .env.local
app/env/client/ShelfName.js
'use client';
export default function ShelfName() {
return <p data-shelf-name>{process.env.NEXT_PUBLIC_SHELF_NAME}</p>;
}
inlined.mjs
import { readdirSync, readFileSync } from 'node:fs';
const chunks = readdirSync(process.env.APP_DIR + '/.next/static/chunks', { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.js'))
.map((entry) => readFileSync(entry.parentPath + '/' + entry.name, 'utf8'));
const inChunks = (text) => chunks.some((chunk) => chunk.includes(text));
console.log('client chunks searched:', chunks.length > 0);
console.log('public value in a client chunk: ', inChunks('Shelf (from .env)'));
console.log('server value in a client chunk: ', inChunks('hello from .env'));
check('there are client chunks to search', chunks.length > 0, true);
check('the NEXT_PUBLIC_ value was written into the code sent to browsers', inChunks('Shelf (from .env)'), true);
check('a server-only value was not', inChunks('hello from .env'), false);
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 "Environments: .env.local, .env.production, .env" ok the log shows "Running next.config.mjs" client chunks searched: true public value in a client chunk: true server value in a client chunk: false ok there are client chunks to search ok the NEXT_PUBLIC_ value was written into the code sent to browsers ok a server-only value was not
The check script ran after the build, with APP_DIR set to the project, and read the files in .next.
The log named the three files in the order they are looked up, .env.local first.
The prefix did what it says: the public value is written into a file in
.next/static/chunks, where any visitor can read it, and the other is in none.
Never give a secret a NEXT_PUBLIC_ name.
Which file wins
A variable can be set in more than one file. The three files each set LEVEL,
two set SHARED_VALUE and one sets APP_GREETING. The page reads them
with process.env on the server, after await connection() so that it
runs for each request.
app/env/page.js
import { connection } from 'next/server';
export default async function Env() {
await connection();
return (
<main data-page="env">
<p data-greeting>{process.env.APP_GREETING}</p>
<p data-shared>{process.env.SHARED_VALUE}</p>
<p data-level>{process.env.LEVEL}</p>
</main>
);
}
order.mjs
const base = process.env.BASE_URL;
const page = async (path) => (await fetch(base + path)).text();
const text = (html, name) => new RegExp('<p data-' + name + '[^>]*>(.*?)</p>').exec(html)[1];
const env = await page('/env');
console.log('GET /env APP_GREETING:', text(env, 'greeting'));
console.log('GET /env SHARED_VALUE:', text(env, 'shared'));
console.log('GET /env LEVEL: ', text(env, 'level'));
check('a value only .env has comes from .env', text(env, 'greeting'), 'hello from .env');
check('.env.production wins over .env', text(env, 'shared'), 'from .env.production');
check('.env.local wins over .env.production', text(env, 'level'), 'from .env.local');
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node order.mjs
GET /env APP_GREETING: hello from .env GET /env SHARED_VALUE: from .env.production GET /env LEVEL: from .env.local ok a value only .env has comes from .env ok .env.production wins over .env ok .env.local wins over .env.production
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.
With next start (production mode) .env.local beat
.env.production, which beat .env, and a name set in one file only came
from that file. A variable that exists in the environment of the process itself beats the value in
.env, which the next panel shows.
Read at build time or at run time
When is a variable read? Server code that runs per request reads it per request. A page that
was built ahead was rendered once, so it holds whatever the variable was then. The panel's
server is a second next start of the same .next directory, started with
APP_GREETING set in its environment (the badge and the note below the output say
so).
app/env/baked/page.js
// No request API is used, so this page is rendered once, at build time.
export default function Baked() {
return (
<main data-page="env-baked">
<p data-greeting>{process.env.APP_GREETING}</p>
</main>
);
}
runtime.mjs
const base = process.env.BASE_URL;
const page = async (path) => (await fetch(base + path)).text();
const text = (html, name) => new RegExp('<p data-' + name + '[^>]*>(.*?)</p>').exec(html)[1];
const env = await page('/env');
const baked = await page('/env/baked');
const client = await page('/env/client');
console.log('GET /env APP_GREETING:', text(env, 'greeting'));
console.log('GET /env/baked APP_GREETING:', text(baked, 'greeting'));
console.log('GET /env/client NEXT_PUBLIC_SHELF_NAME:', text(client, 'shelf-name'));
check('a page that runs per request sees the variable set when next start began', text(env, 'greeting'), 'hello from the shell');
check('a page built ahead kept the value it had at build time', text(baked, 'greeting'), 'hello from .env');
check('the public variable was not touched', text(client, 'shelf-name'), 'Shelf (from .env)');
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node runtime.mjs
GET /env APP_GREETING: hello from the shell GET /env/baked APP_GREETING: hello from .env GET /env/client NEXT_PUBLIC_SHELF_NAME: Shelf (from .env) ok a page that runs per request sees the variable set when next start began ok a page built ahead kept the value it had at build time ok the public variable was not touched
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.
This panel has its own next start of the same .next directory, started with APP_GREETING=hello from the shell in its environment.
The per-request page took the value from the environment of the server. The page built ahead
kept the one in .env, and the public variable did not move either: it was already
part of the code. A third next start sets only the public variable:
public.mjs
const base = process.env.BASE_URL;
const client = await (await fetch(base + '/env/client')).text();
const name = /<p data-shelf-name[^>]*>(.*?)<\/p>/.exec(client)[1];
console.log('GET /env/client NEXT_PUBLIC_SHELF_NAME:', name);
check('a NEXT_PUBLIC_ variable set later does not change what was built in', name, 'Shelf (from .env)');
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node public.mjs
GET /env/client NEXT_PUBLIC_SHELF_NAME: Shelf (from .env) ok a NEXT_PUBLIC_ variable set later does not change what was built in
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.
This panel has its own next start of the same .next directory, started with NEXT_PUBLIC_SHELF_NAME=changed at start in its environment.
Changing a NEXT_PUBLIC_ value therefore means building again. A server-side
value can change with a restart, and only for the code that reads it at run time.
next.config.mjs
The configuration file is plain JavaScript that exports an object. Three of its options are
used in this lesson; a fourth, images, is in the file too and belongs to
lesson 24. redirects() returns a list; permanent: true makes the
status a 308. headers() adds response headers for the addresses that match a
source. poweredByHeader: false stops Next.js from sending
x-powered-by. All three run in the server before any page is looked for, so none
of them needs a component. The log line Running next.config.mjs in the build output
is the file being loaded.
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
// Stop sending the x-powered-by: Next.js header.
poweredByHeader: false,
// A permanent redirect decided by configuration, before any page is looked for.
async redirects() {
return [{ source: '/shelf', destination: '/books', permanent: true }];
},
// Extra response headers for the addresses that match.
async headers() {
return [{ source: '/about', headers: [{ key: 'x-shelf-config', value: 'set in next.config.mjs' }] }];
},
// Lesson 24: the image optimiser only produces the qualities listed here.
images: { qualities: [60, 75] },
};
export default nextConfig;
config.mjs
const base = process.env.BASE_URL;
const get = (path) => fetch(base + path, { redirect: 'manual' });
const shelf = await get('/shelf');
const about = await get('/about');
console.log('/shelf ->', shelf.status, new URL(shelf.headers.get('location'), base).pathname);
console.log('/about x-shelf-config:', about.headers.get('x-shelf-config'));
console.log('/about x-powered-by: ', about.headers.get('x-powered-by'));
check('a configured redirect is permanent', [shelf.status, new URL(shelf.headers.get('location'), base).pathname], [308, '/books']);
check('configured headers are on the matching address', about.headers.get('x-shelf-config'), 'set in next.config.mjs');
check('poweredByHeader: false removed x-powered-by', about.headers.get('x-powered-by'), null);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node config.mjs
/shelf -> 308 /books /about x-shelf-config: set in next.config.mjs /about x-powered-by: null ok a configured redirect is permanent ok configured headers are on the matching address ok poweredByHeader: false removed x-powered-by
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.
Lesson 24 uses the file's images option, and lesson 30 the options that change
what next build writes.
23 Metadata and SEO
A page has a second audience besides the person reading it: programs.
Search engines read the title and description, chat apps build a preview from the
Open Graph tags, and crawlers fetch robots.txt and a sitemap before anything else.
In Next.js none of that is written as HTML. A page or layout exports an object, or a function
that returns one, and Next.js turns it into tags. The tags are in the HTML the server sends, which
is what a crawler reads; they do not wait for JavaScript. (The React course shows
what runs where; this is all server work.)
Static metadata
Export a metadata object from a page.js or layout.js
(a server component; the catalogue shows
what happens when the file is a client one). Relative URLs, like the canonical address below, are resolved
against metadataBase, which the layout sets. The base here is http://localhost,
an address standing in for the real site's; the server in the panel does not listen on it.
The page also renders a JSON-LD script: structured data is not a metadata field, just a script
tag the page prints, with every < escaped so the data cannot close the tag.
app/meta/layout.js
// A layout's metadata is the default for every page under it; the template wraps their titles.
export const metadata = {
metadataBase: new URL('http://localhost'),
title: { template: '%s | Shelf', default: 'Shelf notes' },
description: 'Set by the meta layout.',
};
export default function MetaLayout({ children }) {
return <section data-section="meta">{children}</section>;
}
app/meta/page.js
// Static metadata: an object, known at build time.
export const metadata = {
title: 'Static metadata',
description: 'A page whose title is a fixed string.',
alternates: { canonical: '/meta' },
openGraph: { title: 'Static metadata (for link previews)', type: 'website' },
};
export default function Page() {
// Structured data for search engines is a script tag the page renders itself.
const jsonLd = { '@context': 'https://schema.org', '@type': 'WebPage', name: 'Static metadata' };
return (
<main>
<h1>Static metadata</h1>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
</main>
);
}
static.mjs
const base = process.env.BASE_URL;
const html = await (await fetch(base + '/meta')).text();
const first = (pattern) => (pattern.exec(html) || [])[1];
const title = first(/<title>([^<]*)<\/title>/);
const description = first(/<meta name="description" content="([^"]*)"/);
const canonical = first(/<link rel="canonical" href="([^"]*)"/);
const ogTitle = first(/<meta property="og:title" content="([^"]*)"/);
const twitterCard = first(/<meta name="twitter:card" content="([^"]*)"/);
const jsonLd = JSON.parse(first(/<script type="application\/ld\+json">([^<]*)<\/script>/));
console.log('title: ', title);
console.log('description:', description);
console.log('canonical: ', canonical);
console.log('og:title: ', ogTitle);
console.log('twitter:card', twitterCard);
console.log('json-ld: ', JSON.stringify(jsonLd['@type']), jsonLd.name);
check('the title is the string the page exported, without the layout template', title, 'Static metadata');
check('the description is the page\u2019s, not the layout\u2019s', description, 'A page whose title is a fixed string.');
check('the canonical address was resolved against metadataBase', canonical, 'http://localhost/meta');
check('Open Graph tags came from the openGraph object', ogTitle, 'Static metadata (for link previews)');
check('and a Twitter card tag came with them', twitterCard, 'summary');
check('the JSON-LD script is valid JSON about the page', [jsonLd['@type'], jsonLd.name, jsonLd['@context'].endsWith('schema.org')], ['WebPage', 'Static metadata', true]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node static.mjs
title: Static metadata description: A page whose title is a fixed string. canonical: http://localhost/meta og:title: Static metadata (for link previews) twitter:card summary json-ld: "WebPage" Static metadata ok the title is the string the page exported, without the layout template ok the description is the page’s, not the layout’s ok the canonical address was resolved against metadataBase ok Open Graph tags came from the openGraph object ok and a Twitter card tag came with them ok the JSON-LD script is valid JSON about the page
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 in the object is in the response. Note the title: the layout defines
title.template, and this page sits in the layout's own folder, so the template
did not apply to it. A template only wraps the titles of the segments below the layout that
defines it, which the next panel shows.
Metadata from data: generateMetadata
When the title depends on the address or on data, export generateMetadata
instead. It receives the same params as the page, as a Promise in version 16
(lesson 5), so it is async and awaits them. The panel asks for the
page twice, once with a browser's user agent and once with a crawler's, and reads the title and
description out of the HTML each time.
app/meta/[slug]/page.js
// Dynamic metadata: a function that gets the same params as the page, as a Promise.
export async function generateMetadata({ params }) {
const { slug } = await params;
return { title: `Book: ${slug}`, description: `Everything about ${slug}.` };
}
export default async function Page({ params }) {
const { slug } = await params;
return <h1>{`Book: ${slug}`}</h1>;
}
dynamic.mjs
const base = process.env.BASE_URL;
const read = async (agent) => {
const html = await (await fetch(base + '/meta/dracula', { headers: { 'user-agent': agent } })).text();
return {
title: (/<title>([^<]*)<\/title>/.exec(html) || [])[1],
description: (/<meta name="description" content="([^"]*)"/.exec(html) || [])[1],
};
};
const browser = await read('Mozilla/5.0 (course browser)');
const crawler = await read('Googlebot/2.1');
console.log('browser :', browser.title, '|', browser.description);
console.log('crawler :', crawler.title, '|', crawler.description);
check('generateMetadata made the title from the params, and the layout template wrapped it', browser.title, 'Book: dracula | Shelf');
check('and the description', browser.description, 'Everything about dracula.');
check('a crawler\u2019s user agent gets the same title in the HTML', crawler.title, 'Book: dracula | Shelf');
check('and the same description', crawler.description, 'Everything about dracula.');
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node dynamic.mjs
browser : Book: dracula | Shelf | Everything about dracula. crawler : Book: dracula | Shelf | Everything about dracula. ok generateMetadata made the title from the params, and the layout template wrapped it ok and the description ok a crawler’s user agent gets the same title in the HTML ok and the same description
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 template applied this time, because [slug] is a child segment of
meta.
robots.js and sitemap.js
Two more files in app/ are not pages at all. robots.js returns the
rules for crawlers and Next.js serves them as /robots.txt; sitemap.js
returns a list of addresses and Next.js serves /sitemap.xml. Both are ordinary
functions, so a real site would build the sitemap from its database. Neither reads the request, so
they were made at build time, which the cache header in the panel confirms.
app/robots.js
// app/robots.js becomes /robots.txt.
export default function robots() {
return {
rules: [{ userAgent: '*', allow: '/', disallow: '/account' }],
sitemap: 'http://localhost/sitemap.xml',
};
}
app/sitemap.js
// app/sitemap.js becomes /sitemap.xml.
export default function sitemap() {
return [
{ url: 'http://localhost/', lastModified: new Date('2026-01-01T00:00:00Z') },
{ url: 'http://localhost/meta', lastModified: new Date('2026-02-01T00:00:00Z') },
];
}
files.mjs
const base = process.env.BASE_URL;
const robots = await fetch(base + '/robots.txt');
const robotsText = await robots.text();
const sitemap = await fetch(base + '/sitemap.xml');
const sitemapText = await sitemap.text();
console.log(robots.status, robots.headers.get('content-type'));
console.log(robotsText.trim());
console.log(sitemap.status, sitemap.headers.get('content-type'));
console.log(sitemapText.trim());
const locs = [...sitemapText.matchAll(/<loc>([^<]*)<\/loc>/g)].map((m) => m[1]);
check('robots.js is served as plain text at /robots.txt', [robots.status, robots.headers.get('content-type')], [200, 'text/plain']);
check('with the rules the function returned', robotsText.split('\n').filter(Boolean), ['User-Agent: *', 'Allow: /', 'Disallow: /account', 'Sitemap: http://localhost/sitemap.xml']);
check('sitemap.js is served as XML at /sitemap.xml', [sitemap.status, sitemap.headers.get('content-type')], [200, 'application/xml']);
check('with one <loc> for each entry', locs, ['http://localhost/', 'http://localhost/meta']);
check('and the dates it was given, as ISO strings', [...sitemapText.matchAll(/<lastmod>([^<]*)<\/lastmod>/g)].map((m) => m[1]), ['2026-01-01T00:00:00.000Z', '2026-02-01T00:00:00.000Z']);
check('both were made at build time and are served from the cache', [robots.headers.get('x-nextjs-cache'), sitemap.headers.get('x-nextjs-cache')], ['HIT', 'HIT']);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node files.mjs
200 text/plain User-Agent: * Allow: / Disallow: /account Sitemap: http://localhost/sitemap.xml 200 application/xml <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> <loc>http://localhost/</loc> <lastmod>2026-01-01T00:00:00.000Z</lastmod> </url> <url> <loc>http://localhost/meta</loc> <lastmod>2026-02-01T00:00:00.000Z</lastmod> </url> </urlset> ok robots.js is served as plain text at /robots.txt ok with the rules the function returned ok sitemap.js is served as XML at /sitemap.xml ok with one <loc> for each entry ok and the dates it was given, as ISO strings ok both were made at build time and are served from the cache
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 Disallow line is a request to well-behaved crawlers, not a lock: anyone can
still open /account. What keeps a stranger out of it is the session check in
lesson 27.
Metadata can also come from files: an icon, an opengraph-image
next to a page. They are not used here; the images in this course are all local and plain, and
the next lesson is about them.
24 Images
Images are usually the heaviest thing on a page, and the easiest to get
wrong: too large for the screen, loaded before they are needed, or missing a size so the page
jumps when they arrive. next/image is a component that fixes the markup, and an
endpoint on the Next.js server, /_next/image, that resizes and converts the file
on request. Both work from local files with no network, which is all this lesson uses.
The component
The picture is public/shelf.png, 120 by 80 pixels. The course makes it with a
small script (shown below) so that you can see there is nothing hidden in it, and the file is
kept in the project. The page renders it twice. The first Image gets only what is
required for a file in public/: src, alt,
width and height. The second also sets priority,
quality and sizes. The panel reads the HTML the server sent, and the
header of the PNG file, to compare them.
app/images/page.js
import Image from 'next/image';
export default function Page() {
return (
<main>
<h1>Images</h1>
<Image src="/shelf.png" alt="Five books on a shelf" width={120} height={80} />
<Image src="/shelf.png" alt="The same shelf, high priority" width={120} height={80} priority quality={60} sizes="120px" />
</main>
);
}
scripts/make-png.mjs
// Writes public/shelf.png: a 120 x 80 picture of five books standing on a shelf, drawn pixel
// by pixel and encoded with node:zlib. It is run by hand, once; the file it wrote is in the
// project. Nothing here needs a package, and nothing here touches the network.
import { crc32, deflateSync } from 'node:zlib';
import { writeFileSync } from 'node:fs';
const WIDTH = 120;
const HEIGHT = 80;
const BOOKS = [[200, 60, 60], [60, 120, 200], [230, 180, 50], [70, 160, 100], [150, 90, 180]];
function pixel(x, y) {
if (y >= 70) return [120, 80, 50]; // the shelf board
const book = Math.floor((x - 10) / 20);
const top = 70 - (30 + book * 7); // taller books to the right
if (x >= 10 && x < 110 && y >= top && (x - 10) % 20 < 18) return BOOKS[book];
return [245, 240, 230]; // the wall
}
function chunk(type, data) {
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
const out = Buffer.alloc(body.length + 8);
out.writeUInt32BE(data.length, 0);
body.copy(out, 4);
out.writeUInt32BE(crc32(body), body.length + 4);
return out;
}
const header = Buffer.alloc(13);
header.writeUInt32BE(WIDTH, 0);
header.writeUInt32BE(HEIGHT, 4);
header.set([8, 2, 0, 0, 0], 8); // 8 bits, RGB, deflate, no filter scheme, no interlace
const rows = Buffer.alloc((WIDTH * 3 + 1) * HEIGHT);
for (let y = 0; y < HEIGHT; y++) {
const start = y * (WIDTH * 3 + 1);
rows[start] = 0; // filter type "none"
for (let x = 0; x < WIDTH; x++) rows.set(pixel(x, y), start + 1 + x * 3);
}
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(rows, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
writeFileSync(new URL('../public/shelf.png', import.meta.url), png);
console.log('wrote public/shelf.png,', png.length, 'bytes');
markup.mjs
import { existsSync, readFileSync } from 'node:fs';
const base = process.env.BASE_URL;
const html = await (await fetch(base + '/images')).text();
const png = readFileSync(process.env.APP_DIR + '/public/shelf.png');
const decode = (text) => text.replace(/&/g, '&');
const attr = (tag, name) => {
const m = new RegExp(' ' + name + '="([^"]*)"').exec(tag);
return m ? decode(m[1]) : null;
};
const [normal, priority] = html.match(/<img[^>]*>/g);
const entries = attr(normal, 'srcSet').split(', ');
const widths = attr(priority, 'srcSet').split(', ').map((entry) => /w=(\d+)/.exec(entry)[1]);
const preload = /<link rel="preload" as="image"[^>]*>/.test(html);
console.log('the file: ', png.readUInt32BE(16), 'x', png.readUInt32BE(20), 'pixels');
console.log('first image: ', attr(normal, 'width'), 'x', attr(normal, 'height'), 'loading=' + attr(normal, 'loading'));
console.log('its srcSet: ', entries.map((entry) => entry.replace('/_next/image?url=%2Fshelf.png&', '')).join(' | '));
console.log('second image: ', 'loading=' + attr(priority, 'loading'), 'preload link in the page:', preload);
console.log('with sizes: ', widths.length, 'widths, from', widths[0], 'to', widths.at(-1));
check('the file is a PNG of 120 x 80 pixels', [png.subarray(1, 4).toString(), png.readUInt32BE(16), png.readUInt32BE(20)], ['PNG', 120, 80]);
check('the img carries the size it was given, so the page does not jump', [attr(normal, 'width'), attr(normal, 'height')], ['120', '80']);
check('and loads lazily by default', attr(normal, 'loading'), 'lazy');
check('its src is the optimiser\u2019s address, not the file', attr(normal, 'src').startsWith('/_next/image?url=%2Fshelf.png'), true);
check('its srcSet has one address for 1x screens and one for 2x', entries.map((entry) => entry.split(' ')[1]), ['1x', '2x']);
check('at the first configured widths that are at least 120 and 240 pixels', entries.map((entry) => /w=(\d+)/.exec(entry)[1]), ['128', '256']);
check('priority takes lazy loading away', attr(priority, 'loading'), null);
check('and puts a preload link for the image in the page', preload, true);
check('with sizes, the srcSet lists every configured width', [widths.length, widths[0], widths.at(-1)], [15, '32', '3840']);
check('and the quality prop is in every address', attr(priority, 'srcSet').split(', ').every((entry) => entry.includes('q=60')), true);
check('the native image library that does the work is part of the vendored tree', existsSync(process.env.APP_DIR + '/node_modules/@img/sharp-darwin-x64'), true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node markup.mjs
the file: 120 x 80 pixels first image: 120 x 80 loading=lazy its srcSet: w=128&q=75 1x | w=256&q=75 2x second image: loading=null preload link in the page: true with sizes: 15 widths, from 32 to 3840 ok the file is a PNG of 120 x 80 pixels ok the img carries the size it was given, so the page does not jump ok and loads lazily by default ok its src is the optimiser’s address, not the file ok its srcSet has one address for 1x screens and one for 2x ok at the first configured widths that are at least 120 and 240 pixels ok priority takes lazy loading away ok and puts a preload link for the image in the page ok with sizes, the srcSet lists every configured width ok and the quality prop is in every address ok the native image library that does the work is part of the vendored tree
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 size attributes are there for the browser's layout: with them, the space for the picture
is reserved before a byte of it arrives. The srcSet lets the browser pick the
smaller address on an ordinary screen and the larger on a sharp one, and each address is a
request to the optimiser, with the width and the quality in the query string. Without
sizes the component assumes a fixed size and gives two addresses; with
sizes it describes a flexible one and gives a ladder of widths for the browser to
choose from. If you leave out width and height for a file that is not
imported, the component throws: the catalogue has the message next dev printed.
The optimiser
A request to /_next/image names the file (url), a width
(w) and a quality (q). The server reads the file, resizes and
re-encodes it, and picks the format from the Accept header the browser sent: a
browser that says it takes WebP gets WebP, and one that asks for PNG gets the original back. Only the
widths and qualities the configuration allows are served, because otherwise any visitor could
ask the server to do unlimited work. The widths are the ladder above. The qualities are a list in
next.config.mjs (the file lesson 22 introduced). The panel asks for each case and
reads the first bytes of what comes back to see the real format.
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
// Stop sending the x-powered-by: Next.js header.
poweredByHeader: false,
// A permanent redirect decided by configuration, before any page is looked for.
async redirects() {
return [{ source: '/shelf', destination: '/books', permanent: true }];
},
// Extra response headers for the addresses that match.
async headers() {
return [{ source: '/about', headers: [{ key: 'x-shelf-config', value: 'set in next.config.mjs' }] }];
},
// Lesson 24: the image optimiser only produces the qualities listed here.
images: { qualities: [60, 75] },
};
export default nextConfig;
optimiser.mjs
import { readFileSync } from 'node:fs';
const base = process.env.BASE_URL;
const ask = async (query, accept) => {
const res = await fetch(base + '/_next/image?url=%2Fshelf.png&' + query, { headers: { accept } });
const body = Buffer.from(await res.arrayBuffer());
return { status: res.status, type: res.headers.get('content-type'), vary: res.headers.get('vary'), csp: res.headers.get('content-security-policy'), body };
};
const webp = await ask('w=128&q=75', 'image/webp');
const png = await ask('w=128&q=75', 'image/png');
const sixty = await ask('w=128&q=60', 'image/webp');
const fifty = await ask('w=128&q=50', 'image/webp');
const huge = await ask('w=3000&q=75', 'image/webp');
const docs = readFileSync(process.env.APP_DIR + '/node_modules/next/dist/docs/01-app/03-api-reference/02-components/image.md', 'utf8');
console.log('Accept webp, w=128 q=75 ->', webp.status, webp.type);
console.log('Accept png, w=128 q=75 ->', png.status, png.type);
console.log('Accept webp, w=128 q=60 ->', sixty.status);
console.log('Accept webp, w=128 q=50 ->', fifty.status, fifty.body.toString());
console.log('Accept webp, w=3000 ->', huge.status, huge.body.toString());
check('a browser that accepts WebP gets WebP', [webp.status, webp.type, webp.body.subarray(0, 4).toString(), webp.body.subarray(8, 12).toString()], [200, 'image/webp', 'RIFF', 'WEBP']);
check('one that does not gets the original format', [png.status, png.type, png.body.subarray(1, 4).toString()], [200, 'image/png', 'PNG']);
check('the answer depends on the Accept header, and says so', webp.vary, 'Accept');
check('it also forbids scripts in what it serves', webp.csp.includes("script-src 'none'"), true);
check('a quality the configuration lists is served', sixty.status, 200);
check('one it does not list is refused', [fifty.status, fifty.body.toString()], [400, '"q" parameter (quality) of 50 is not allowed']);
check('and so is a width that is not on the ladder', [huge.status, huge.body.toString()], [400, '"w" parameter (width) of 3000 is not allowed']);
check('the vendored documentation says the qualities list is required since version 16', docs.includes('This field is required starting with Next.js 16'), true);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node optimiser.mjs
Accept webp, w=128 q=75 -> 200 image/webp Accept png, w=128 q=75 -> 200 image/png Accept webp, w=128 q=60 -> 200 Accept webp, w=128 q=50 -> 400 "q" parameter (quality) of 50 is not allowed Accept webp, w=3000 -> 400 "w" parameter (width) of 3000 is not allowed ok a browser that accepts WebP gets WebP ok one that does not gets the original format ok the answer depends on the Accept header, and says so ok it also forbids scripts in what it serves ok a quality the configuration lists is served ok one it does not list is refused ok and so is a width that is not on the ladder ok the vendored documentation says the qualities list is required since version 16
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 script that wrote the picture is above, in the first panel's files, and it runs by hand:
node scripts/make-png.mjs. It builds the pixels in memory and writes a PNG with
node:zlib; there is no image library in it.
Images from other sites
An address on another host is refused by the optimiser unless the configuration names the host
in images.remotePatterns, again so that a stranger cannot make your server fetch
anything. This course has no network, so it cannot show one working; the shape of the setting is
below.
// next.config.mjs
export default {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.example.com', pathname: '/covers/**' },
],
},
};
a remote image needs a remote host, and nothing in this course may reach one. The setting is documented in the vendored package, under next/dist/docs.
25 Fonts and CSS
Fonts and styles are the other assets of a page. Next.js has an answer for each that also works with no network: a font file in your project becomes a file the server hosts itself, with its own cache headers, and CSS is compiled and shipped as ordinary stylesheets. (How CSS works in the browser is the business of a CSS course; this lesson shows what Next.js does with it.)
A font from a file
next/font/local reads a font file at build time. The file here is Inter,
regular weight, 48,432 bytes, cut out of the React course's own stylesheet by the script in the
project (scripts/extract-font.mjs, run once by hand, not shown on this page because
it reads a file outside the project). The call returns an object with a
className, and the layout puts that class on an element. The panel then checks
the four things that matter: the page asks the browser to preload the font, the font is
served from this server, the stylesheet declares it, and the rule that uses it names a
fallback.
app/styled/layout.js
import localFont from 'next/font/local';
import './styled.css';
// The font file is in the project: nothing is fetched, at build time or later.
const inter = localFont({ src: '../fonts/Inter-400.woff2', weight: '400', style: 'normal', display: 'swap' });
export default function StyledLayout({ children }) {
return <div className={inter.className} data-section="styled">{children}</div>;
}
font.mjs
import { readFileSync } from 'node:fs';
const base = process.env.BASE_URL;
const html = await (await fetch(base + '/styled')).text();
const get = async (path) => {
const res = await fetch(base + path);
return { status: res.status, type: res.headers.get('content-type'), body: Buffer.from(await res.arrayBuffer()) };
};
const preload = /<link rel="preload" href="([^"]*)" as="font" crossorigin="" type="font\/woff2"\/>/.exec(html);
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="([^"]*)"/g)].map((m) => m[1]);
const css = (await Promise.all(stylesheets.map(async (path) => (await get(path)).body.toString()))).join('\n');
const font = await get(preload[1]);
const file = readFileSync(process.env.APP_DIR + '/app/fonts/Inter-400.woff2');
const fontClass = /<div class="(inter_[^"]*__className)"/.exec(html)[1];
console.log('preload link for a font under /_next/static/media/:', preload[1].startsWith('/_next/static/media/Inter_400'));
console.log('the font answers:', font.status, font.type, font.body.subarray(0, 4).toString());
console.log('same bytes as the file in the project:', font.body.equals(file));
console.log('stylesheets linked:', stylesheets.length);
console.log('@font-face with swap:', css.includes('@font-face') && css.includes('font-display:swap'));
console.log('a fallback face with size-adjust:', css.includes('inter Fallback') && css.includes('size-adjust'));
check('the page preloads a font file hosted under /_next/static/media', [preload[1].startsWith('/_next/static/media/Inter_400'), preload[1].endsWith('.woff2')], [true, true]);
check('the file in the project is the 48,432 bytes the text says', file.length, 48432);
check('the server answers for it with a WOFF2 file', [font.status, font.body.subarray(0, 4).toString()], [200, 'wOF2']);
check('and the bytes are the ones in the project', font.body.equals(file), true);
check('the stylesheet declares the face with the weight and the display setting', ['@font-face{font-family:inter;src:url(../media/Inter_400', 'font-weight:400', 'font-display:swap'].map((part) => css.includes(part)), [true, true, true]);
check('and a metric-matched fallback face, so text does not jump when the font arrives', css.includes('inter Fallback') && css.includes('size-adjust'), true);
check('the class from next/font sets the family with that fallback', css.includes('.' + fontClass + '{font-family:inter,inter Fallback'), true);
check('nothing in the page is loaded from another host', [...html.matchAll(/(?:href|src)="(https?:[^"]*)"/g)].length, 0);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node font.mjs
preload link for a font under /_next/static/media/: true the font answers: 200 font/woff2 wOF2 same bytes as the file in the project: true stylesheets linked: 2 @font-face with swap: true a fallback face with size-adjust: true ok the page preloads a font file hosted under /_next/static/media ok the file in the project is the 48,432 bytes the text says ok the server answers for it with a WOFF2 file ok and the bytes are the ones in the project ok the stylesheet declares the face with the weight and the display setting ok and a metric-matched fallback face, so text does not jump when the font arrives ok the class from next/font sets the family with that fallback ok nothing in the page is loaded from another host
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 layout asked for display: 'swap', so the browser shows text in the fallback at
once and swaps when the file arrives; the second face, whose metrics were adjusted to match the
font, keeps the swap from moving the text around. The file name has a content hash in it
(this page does not print it, because it changes with the file), so it can be cached forever.
Global CSS and CSS Modules
A plain .css file imported by a layout applies to the whole page; Next.js adds a
link for it. A file named *.module.css is a CSS Module: each class is
renamed to a name that is unique to the file, and importing the file gives you an object from the
original names to the new ones. Two components can both have a .card class and not
collide. Both kinds are compiled, minified and served as stylesheets under
/_next/static/chunks.
app/styled/styled.css
/* Global CSS: imported by a layout, applied to the whole page. */
[data-section='styled'] h1 {
color: rebeccapurple;
}
app/styled/Card.module.css
/* A CSS Module: the class names are made unique for this file. */
.card {
border: 1px solid #999;
padding: 8px;
}
app/styled/page.js
import styles from './Card.module.css';
export default function Page() {
return (
<main>
<h1>Fonts and CSS</h1>
<p className={styles.card}>A card styled by a CSS Module.</p>
</main>
);
}
css.mjs
const base = process.env.BASE_URL;
const html = await (await fetch(base + '/styled')).text();
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="([^"]*)"/g)].map((m) => m[1]);
const css = (await Promise.all(stylesheets.map(async (path) => (await fetch(base + path)).text()))).join('\n');
const card = /<p class="(Card-module__[^"]*__card)"/.exec(html)[1];
console.log('stylesheets:', stylesheets.length, stylesheets.every((path) => path.startsWith('/_next/static/chunks/') && path.endsWith('.css')));
console.log('the class in the page looks like Card-module__<hash>__card:', /^Card-module__[\w-]+__card$/.test(card));
console.log('that class is in the stylesheet:', css.includes('.' + card + '{'));
console.log('the global rule is in the stylesheet:', css.includes('[data-section=styled] h1{'));
check('the page links stylesheets under /_next/static/chunks', stylesheets.length > 0 && stylesheets.every((path) => path.startsWith('/_next/static/chunks/') && path.endsWith('.css')), true);
check('a CSS Module class was renamed, and the page uses the new name', /^Card-module__[\w-]+__card$/.test(card), true);
check('the stylesheet has a rule for that name and the file\u2019s declarations', css.includes('.' + card + '{border:1px solid #999;padding:8px}'), true);
check('the global rule is in a stylesheet', css.includes('[data-section=styled] h1{'), true);
check('and was minified: the colour name in the source became a short hex code', [css.includes('rebeccapurple'), css.includes('color:#639')], [false, true]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node css.mjs
stylesheets: 2 true the class in the page looks like Card-module__<hash>__card: true that class is in the stylesheet: true the global rule is in the stylesheet: true ok the page links stylesheets under /_next/static/chunks ok a CSS Module class was renamed, and the page uses the new name ok the stylesheet has a rule for that name and the file’s declarations ok the global rule is in a stylesheet ok and was minified: the colour name in the source became a short hex code
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 class name in the page is whatever the module system made it, which is why a component
imports styles.card instead of writing "card". The hash in the middle
is not shown on this page for the same reason as the font's.
What this course does not run
Two things are normal in a Next.js project and impossible here. The Google variant of the font module downloads the font files from Google when you build, and then serves them yourself; the build needs the network for that. Tailwind CSS and Sass are packages outside the vendored tree.
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
the Google variant of the font module fetches the font from a Google address while the build runs, and nothing in this course may reach the network (the audit of this course rejects a project that uses it). The call looks the same as the local one once the font is fetched.