Emmanuel Akinfulubi

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.

Which environment files the build read, and what reached the browser

ran at build time · next build · Next 16.3.1

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.

Which file wins, in next start

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

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).

next start with APP_GREETING set in its environment

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

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:

next start with NEXT_PUBLIC_SHELF_NAME set in its environment

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

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.

redirects, headers and poweredByHeader in next.config.mjs

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

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.

The tags of a page with a metadata object

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

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.

generateMetadata, read as a browser and as a crawler

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

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.

/robots.txt and /sitemap.xml

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

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.

What next/image put in the page

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

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(/&amp;/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.

Asking /_next/image for each case

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

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.

remotePatterns: allow one host and one folder

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

next/font/local: the preload link, the file and the stylesheet

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

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.

A CSS Module and a global stylesheet

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

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.

The Google variant, the usual way to get Inter

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

Newsletter

New writing, when it lands.

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