Emmanuel Akinfulubi

Starting out all 3 written

What a framework adds to React, what a Next.js project looks like on disk, and the three commands: dev, build and start.

1 Why a framework, and what Next.js adds

React draws a screen from data and has no opinion about anything else. A real site also needs addresses that lead to pages, a server that sends HTML, a way to get data, code that is compiled and split for the browser, and somewhere to answer requests that are not pages. Next.js is a framework that supplies those, with React on top. This lesson looks at what it sends back, using a small project the course built and started on this machine.

What "really ran" means here. Every panel in this course is executed when the course is built. A panel that talks to a server talks to a real next start the build launched on 127.0.0.1, on a port it chose, inside a sandbox that allows nothing but loopback. Lesson 3 shows the sandbox. The badge on each panel names the command.

What React leaves out, and what Next.js adds

The React course starts from one idea: an element describes a piece of screen, and a renderer turns descriptions into HTML. That is all React does. Next.js sits around it:

  • Routing. A folder and a file name are an address. There is no route table to write (lesson 4).
  • Rendering on the server. Components run on the server by default, and the HTML they produce is what the browser receives first (lessons 8 to 12).
  • Data and caching. A server component can await its data, and Next.js decides what to remember (lessons 13 to 16).
  • Endpoints. A route.js file answers requests that are not pages, and Server Actions answer form posts (lessons 17 and 19).
  • A build. Your code is compiled, split into files for the browser, and prerendered where it can be (lesson 3 and lesson 29).

Underneath it is still an HTTP server, the kind the Node.js course builds by hand. The difference is that you write components and files, and Next.js writes the server.

The first response already holds the page

The project the panels below talk to has one page. Its source is shown above the output, and it is the real file Next.js compiled. The driver script asks the running server for / with fetch and looks at the text that comes back, before any JavaScript could have run.

GET / against next start

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

app/layout.js

export const metadata = {
  title: 'Shelf',
  description: 'A small app for the Next.js course',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <div data-layout="root">{children}</div>
      </body>
    </html>
  );
}

app/page.js

import { version } from 'react';
import ClientVersion from './ClientVersion';

export default function Home() {
  return (
    <main>
      <h1>Hello from the server</h1>
      <p>
        Rendered by React <span data-where="server">{version}</span> on the server, and by React{' '}
        <ClientVersion /> in a client component.
      </p>
    </main>
  );
}

home.mjs

const base = process.env.BASE_URL;
const res = await fetch(base + '/');
const html = await res.text();

console.log(res.status, res.headers.get('content-type'));
console.log(html.match(/<h1>.*?<\/h1>/)[0]);
console.log(html.match(/<title>.*?<\/title>/)[0]);

check('status', res.status, 200);
check('content type', res.headers.get('content-type'), 'text/html; charset=utf-8');
check('the page content is in the HTML', html.includes('<h1>Hello from the server</h1>'), true);
check('the title comes from the layout', html.includes('<title>Shelf</title>'), true);
check('React payload for the browser is in the same response', html.includes('self.__next_f.push'), true);

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

node home.mjs

200 text/html; charset=utf-8
<h1>Hello from the server</h1>
<title>Shelf</title>
ok   status
ok   content type
ok   the page content is in the HTML
ok   the title comes from the layout
ok   React payload for the browser is in the same response

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

The response is a whole HTML document. The heading is in it as text, so a search engine, a browser with JavaScript off, or fetch sees the page without running anything. The same response also carries a self.__next_f.push script: that is the data the browser's copy of React needs to take over the page, and lesson 9 returns to it.

Pages are not the only thing a server answers

A file called route.js answers a request directly, with no page around it. A file in public/ is served as it is, from the root of the site.

A route handler and a public file

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

app/api/hello/route.js

export function GET() {
  return Response.json({ hello: 'world' });
}

endpoints.mjs

const base = process.env.BASE_URL;

const api = await fetch(base + '/api/hello');
console.log(api.status, api.headers.get('content-type'));
const body = await api.json();
console.log(body);

const file = await fetch(base + '/hello.txt');
console.log(file.status, file.headers.get('content-type'));
console.log((await file.text()).trim());

const missing = await fetch(base + '/nope');
console.log(missing.status);

check('a route handler answers with JSON', body, { hello: 'world' });
check('as application/json', api.headers.get('content-type'), 'application/json');
check('a file in public/ is served from the root', file.status, 200);
check('an address with no file behind it is a 404', missing.status, 404);

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

node endpoints.mjs

200 application/json
{ hello: 'world' }
200 text/plain; charset=UTF-8
A file in public/ is served as it is.
404
ok   a route handler answers with JSON
ok   as application/json
ok   a file in public/ is served from the root
ok   an address with no file behind it is a 404

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.

Which React runs

The project's package.json asks for React 19.2.7, and that is what sits in node_modules. The page above rendered the React version twice, once in a server component and once in a client component. The next panel reads both out of the HTML and compares them with node_modules.

Which React the App Router runs

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

app/ClientVersion.js

'use client';

import { version } from 'react';

export default function ClientVersion() {
  return <span data-where="client">{version}</span>;
}

react-version.mjs

import { readFileSync } from 'node:fs';

const html = await (await fetch(process.env.BASE_URL + '/')).text();
const pick = (where) => new RegExp('data-where="' + where + '">([^<]+)<').exec(html)[1];
const server = pick('server');
const client = pick('client');
const installed = JSON.parse(
  readFileSync(process.env.APP_DIR + '/node_modules/react/package.json', 'utf8'),
).version;

console.log('server component: ', server);
console.log('client component: ', client);
console.log('node_modules/react:', installed);

check('server and client components render with the same React', server, client);
check('node_modules/react is what package.json asks for', installed, '19.2.7');
check('the App Router does not use that copy', server !== installed, true);
check('it uses a canary build of React 19', /^19\.\d+\.\d+-canary-/.test(server), true);

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

node react-version.mjs

server component:  19.3.0-canary-cbb046ab-20260731
client component:  19.3.0-canary-cbb046ab-20260731
node_modules/react: 19.2.7
ok   server and client components render with the same React
ok   node_modules/react is what package.json asks for
ok   the App Router does not use that copy
ok   it uses a canary build of React 19

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 App Router renders with a copy of React that ships inside the next package, not the one in node_modules, and that copy is a canary build of React 19. The Next.js installation guide says the same in its own words (it is in next/dist/docs, inside the vendored package). You still list react and react-dom in package.json, for the tools that read it. For the React you write, nothing changes: the features in the React course, such as server rendering, are what this copy provides.

2 A project from nothing, and its layout

There is no generator you must use and no hidden configuration. A Next.js project is a handful of files, and this lesson looks at all of them in the project the course built.

The files that make a project

Three things turn a folder into a Next.js project: a package.json that lists next, react and react-dom and has the three scripts, a next.config.mjs (optional, every option has a default), and an app folder whose layout and page files are the site. The package file is described in the Node.js course, and the .mjs extension means an ES module, as in that lesson. The layout and the page in app/ are plain function components, the kind the React course writes first.

The project on disk

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

package.json

{
  "name": "app",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.3.1",
    "react": "19.2.7",
    "react-dom": "19.2.7"
  }
}

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;

app/layout.js

export const metadata = {
  title: 'Shelf',
  description: 'A small app for the Next.js course',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <div data-layout="root">{children}</div>
      </body>
    </html>
  );
}

app/page.js

import { version } from 'react';
import ClientVersion from './ClientVersion';

export default function Home() {
  return (
    <main>
      <h1>Hello from the server</h1>
      <p>
        Rendered by React <span data-where="server">{version}</span> on the server, and by React{' '}
        <ClientVersion /> in a client component.
      </p>
    </main>
  );
}

project.mjs

import { readdirSync, readFileSync, statSync } from 'node:fs';

const root = process.env.APP_DIR;

function tree(dir, prefix = '') {
  const lines = [];
  for (const name of readdirSync(dir).sort()) {
    if (name === 'node_modules' || name === '.next') continue;
    const dirp = statSync(dir + '/' + name).isDirectory();
    lines.push(prefix + name + (dirp ? '/' : ''));
    if (dirp) lines.push(...tree(dir + '/' + name, prefix + '  '));
  }
  return lines;
}

const lines = tree(root);
console.log(lines.join('\n'));

const pkg = JSON.parse(readFileSync(root + '/package.json', 'utf8'));
const next = JSON.parse(readFileSync(root + '/node_modules/next/package.json', 'utf8'));
console.log(pkg.scripts);
console.log('installed next:', next.version);

const served = await (await fetch(process.env.BASE_URL + '/hello.txt')).text();
const direct = await fetch(process.env.BASE_URL + '/public/hello.txt');

check('the project has an app folder with a root layout and page', lines.includes('app/') && lines.includes('  layout.js') && lines.includes('  page.js'), true);
check('and a public folder', lines.includes('public/'), true);
check('the three scripts are next dev, next build and next start', pkg.scripts, { dev: 'next dev', build: 'next build', start: 'next start' });
check('the installed Next.js', next.version, '16.3.1');
check('public/hello.txt is served from the root', served, readFileSync(root + '/public/hello.txt', 'utf8'));
check('and not from /public', direct.status, 404);

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

node project.mjs

.env
.env.local
.env.production
app/
  (marketing)/
    layout.js
    pricing/
      page.js
  ClientVersion.js
  _drafts/
    page.js
  _lib/
    books.js
    calls.js
    report.js
    session-core.js
    session.js
    shelf.js
    store.js
    users.js
    validate.js
  about/
    page.js
  account/
    actions.js
    page.js
  actions/
    actions.js
    done/
      page.js
    page.js
  api/
    books/
      [id]/
        route.js
      route.js
    cookie/
      route.js
    hello/
      route.js
    me/
      route.js
    revalidate/
      route.js
    static/
      route.js
    time/
      route.js
  auth/
    actions.js
  authors/
    [name]/
      not-found.js
      page.js
  books/
    NavLink.js
    [slug]/
      page.js
    layout.js
    page.js
  cache/
    default/
      page.js
    forced/
      page.js
    isr/
      page.js
    static/
      page.js
    timed/
      page.js
    unstable/
      page.js
  clock/
    connection/
      page.js
    cookies/
      page.js
    dynamic/
      page.js
    search/
      page.js
    static/
      page.js
  counter/
    Counter.js
    describe.js
    page.js
  dashboard/
    layout.js
    page.js
    settings/
      page.js
  data/
    basic/
      page.js
    memo/
      page.js
    parallel/
      page.js
    waterfall/
      page.js
  docs/
    [...parts]/
      page.js
  env/
    baked/
      page.js
    client/
      ShelfName.js
      page.js
    leak/
      Leaky.js
      page.js
    page.js
  flaky/
    error.js
    page.js
  fonts/
    Inter-400.woff2
  forms/
    SignupForm.js
    actions.js
    signup/
      page.js
    thanks/
      page.js
  guide/
    [[...parts]]/
      page.js
  images/
    page.js
  layout.js
  library/
    page.js
  login/
    page.js
  meta/
    [slug]/
      page.js
    layout.js
    page.js
  moved/
    page.js
  not-found.js
  old-shelf/
    page.js
  page.js
  proxy-demo/
    new/
      page.js
  reports/
    loading.js
    page.js
  request/
    bad-set/
      page.js
    page.js
  robots.js
  signup/
    page.js
  sitemap.js
  streaming/
    blocking/
      page.js
    page.js
  studio/
    Collapsible.js
    ServerNote.js
    ThemeProvider.js
    layout.js
    page.js
  styled/
    Card.module.css
    layout.js
    page.js
    styled.css
  tags/
    action/
      page.js
    actions.js
    list/
      page.js
    path/
      page.js
data/
  shelf.json
next.config.mjs
package.json
proxy.js
public/
  hello.txt
  shelf.png
scripts/
  extract-font.mjs
  make-png.mjs
{ dev: 'next dev', build: 'next build', start: 'next start' }
installed next: 16.3.1
ok   the project has an app folder with a root layout and page
ok   and a public folder
ok   the three scripts are next dev, next build and next start
ok   the installed Next.js
ok   public/hello.txt is served from the root
ok   and not from /public

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 listing is the whole project except node_modules and .next, the folder next build writes (lesson 3). In app/, only the files named page become addresses; layout.js wraps them, and ClientVersion.js is an ordinary component that happens to live next to them. A file in public/ is served as it is, and its folder name is not part of the address.

JavaScript, not TypeScript

The files are .js, and they contain JSX. Next.js compiles JSX in .js files, so nothing needs renaming. Most real projects use TypeScript. This course does not, because TypeScript is not part of what is vendored here, and Next.js needs the typescript package as soon as a project has TypeScript files; getting it means the npm registry, which this course never contacts. The root layout would look like this, and it is shown without being run.

app/layout.tsx, the TypeScript version of the root layout

shown, not run
export const metadata = {
  title: 'Shelf',
  description: 'A small app for the Next.js course',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

needs the typescript package, which is not vendored here; getting it means the npm registry, which this offline course never contacts.

Starting a project on your own machine

On a machine with network access the usual start is a generator, which asks a few questions, writes these files and installs the packages. It is shown and not run, because it downloads from the npm registry. Doing the same by hand is exactly the three files above and the three packages named in package.json.

The generator

shown, not run
npx create-next-app@latest my-app
cd my-app
npm run dev

downloads create-next-app and every dependency from the npm registry, which this offline course never contacts. The project the panels use was written by hand, file by file.

A src/ folder is optional: if you put app inside src, Next.js finds it there. Nothing else changes, and the course does not use it.

3 Dev, build and start

A Next.js project has three commands, and they are three different programs sharing one code base. This lesson runs each of them against the real thing and reads what they print. Each one is an ordinary Node.js program started from a terminal, as in the Node.js course, and the two servers are HTTP servers like the one in that course.

next dev: compile what is asked for

next dev is for writing code. It starts a server and compiles a page the first time someone asks for it, then keeps the result. The driver below asks for / twice, then for /about, and only prints booleans for the timing: the first request has to compile, so it is slower than the second by a wide margin on any machine. The second output block is what next dev itself printed, from its start to the end of the driver. The project is a separate small one, dev, because a project is either run in development or built, never both.

Requests to next dev

ran at build time · next dev · Next 16.3.1

app/page.js

export default function Home() {
  return (
    <main>
      <h1>Hello from next dev</h1>
    </main>
  );
}

dev.mjs

import { readdirSync } from 'node:fs';

const base = process.env.BASE_URL;

async function timed(path) {
  const started = performance.now();
  const res = await fetch(base + path);
  await res.text();
  return { status: res.status, ms: performance.now() - started };
}

const first = await timed('/');
const second = await timed('/');
const about = await timed('/about');

console.log('GET /      ', first.status);
console.log('GET / again', second.status);
console.log('GET /about ', about.status);
console.log('the first request took more than three times the second:', first.ms > second.ms * 3);
console.log('.next holds:', readdirSync(process.env.APP_DIR + '/.next').sort().join(' '));

check('every request was answered', [first.status, second.status, about.status], [200, 200, 200]);
check('next dev keeps its files in .next/dev and nothing else', readdirSync(process.env.APP_DIR + '/.next').sort(), ['dev']);
check('the first request had to compile', first.ms > second.ms * 3, true);

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

node dev.mjs

GET /       200
GET / again 200
GET /about  200
the first request took more than three times the second: true
.next holds: dev
ok   every request was answered
ok   next dev keeps its files in .next/dev and nothing else
ok   the first request had to compile

What next dev printed meanwhile

▲ Next.js 16.3.1 (Turbopack)
- Local:         http://127.0.0.1:<port>
- Network:       http://127.0.0.1:<port>
✓ Ready in <t>
✓ Running next.config.mjs took <t>

 GET / 200 in <t> (next.js: <t>, application-code: <t>)
 GET / 200 in <t> (next.js: <t>, application-code: <t>)
 GET /about 200 in <t> (next.js: <t>, application-code: <t>)

The driver ran against next dev -H 127.0.0.1 -p 0 of project dev; BASE_URL is the address Next printed.

Each request is logged with its status and how long it took; the durations are replaced by <t> on this page because they differ on every run. In this mode .next holds only a dev folder; the production output of the next section does not exist yet.

next build: make the production output

next build compiles the whole project, prerenders every page that does not depend on the request, and writes the result to .next. It ends with a table of the routes it found. Lesson 29 reads that table line by line; for now the symbols matter: ○ is a page that was rendered once, at build time, and ƒ is code that runs on each request. The panel runs the build of the app project and then a small script that looks inside .next.

next build of the app project

ran at build time · next build · Next 16.3.1

inspect.mjs

import { readFileSync, statSync } from 'node:fs';

const root = process.env.APP_DIR;
const id = readFileSync(root + '/.next/BUILD_ID', 'utf8');
const routes = Object.keys(JSON.parse(readFileSync(root + '/.next/prerender-manifest.json', 'utf8')).routes).sort();
const swc = statSync(root + '/node_modules/@next/swc-darwin-x64/next-swc.darwin-x64.node');

console.log('build id characters:', id.length);
console.log('prerendered:', routes.join(' '));
console.log('native compiler is a file of more than a megabyte:', swc.size > 1_000_000);

check('the build wrote an id of 21 characters', id.length, 21);
check('it prerendered the static pages of lessons 1 to 4', ['/', '/_global-error', '/_not-found', '/about', '/dashboard', '/dashboard/settings'].map((r) => routes.includes(r)), [true, true, true, true, true, true]);
check('and not the route handler', routes.includes('/api/hello'), false);
check('the compiler is the native binary that came with the vendored tree', swc.size > 1_000_000, true);

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

next build

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

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

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


ƒ Proxy (Middleware)

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

ok   the log shows "Compiled successfully"
ok   the log shows "Route (app)"
ok   the log shows "○  (Static)"
ok   the log shows "ƒ  (Dynamic)"
ok   the log does not show "anonymous telemetry"
build id characters: 21
prerendered: / /_global-error /_not-found /about /actions/done /api/static /books /books/dracula /books/middlemarch /books/persuasion /cache/isr /cache/static /clock/static /counter /dashboard /dashboard/settings /env/baked /env/client /env/leak /forms/signup /images /library /meta /moved /old-shelf /pricing /robots.txt /sitemap.xml /studio /styled /tags/path
native compiler is a file of more than a megabyte: true
ok   the build wrote an id of 21 characters
ok   it prerendered the static pages of lessons 1 to 4
ok   and not the route handler
ok   the compiler is the native binary that came with the vendored tree

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

The build id is generated per build and is masked on this page, as are the durations and the number of workers. The log ends with the legend for the symbols, and the checks below it are the ones this course made about the log and about .next.

next start: serve what was built

next start serves .next and compiles nothing. It listens on all interfaces unless told otherwise: the command line help in the vendored package says (default: 0.0.0.0) for -H. The course always passes -H 127.0.0.1 -p 0, so the server is reachable only from this machine, on a port the operating system picks. -p 0 works because Next.js prints the address it ended up with, and that line is how the build learns the port.

GET /about against next start

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

app/about/page.js

export default function About() {
  return (
    <main>
      <h1>About</h1>
      <p>This page is a file at app/about/page.js.</p>
    </main>
  );
}

start.mjs

import { readFileSync } from 'node:fs';

const base = process.env.BASE_URL;
const a = await (await fetch(base + '/about')).text();
const res = await fetch(base + '/about');
const b = await res.text();

const help = readFileSync(process.env.APP_DIR + '/node_modules/next/dist/bin/next', 'utf8');

console.log(res.status, 'cache-control:', res.headers.get('cache-control'));
console.log('the same page twice:', a === b);

check('a static page is the same on every request', a === b, true);
check('and is served with a long shared-cache lifetime', res.headers.get('cache-control'), 's-maxage=31536000');
check('the hostname flag defaults to every interface', help.includes('Specify a hostname on which to start the application (default: 0.0.0.0).'), true);

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

node start.mjs

200 cache-control: s-maxage=31536000
the same page twice: true
ok   a static page is the same on every request
ok   and is served with a long shared-cache lifetime
ok   the hostname flag defaults to every interface

What next start printed meanwhile

▲ Next.js 16.3.1
- Local:         http://127.0.0.1:<port>
- Network:       http://127.0.0.1:<port>
✓ Ready in <t>
✓ Running next.config.mjs took <t>

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.

How this course keeps Next.js off the network

Next.js has a few code paths that can reach the internet: anonymous telemetry, a version check the development server makes when a browser's hot-reload socket connects, downloading a compiler when the native one will not load, and fetching Google Fonts or remote images when a project asks for them. None of them may run here, so three layers stand in the way.

  1. The vendored tree contains the native compiler (the swc package for this machine), so nothing needs downloading. The build refuses to run unless every file matches the hashes in tools/vendor.json.
  2. Every process that runs Next.js has NEXT_TELEMETRY_DISABLED=1, and npm is pointed at an address nothing listens on. The build panel above checks that the log has no telemetry notice.
  3. Every Next.js process runs under sandbox-exec with the profile below. It allows loopback and, for anything else, kills the process with SIGKILL. A Next.js process that tried to reach the internet would not print an error; it would die, and the build would stop with a message saying so. Before any panel runs, a canary proves the profile kills: the contents page shows network canaries: 4 of 4 blocked. The wall stops connections. It does not stop name lookups: on macOS the system resolver answers those from outside the sandbox, so a lookup alone would not kill a process. That is likely rather than tested, because this course makes no lookup to see.

tools/tripwire.sb

shown, not run: the course's own tool
(version 1)
(allow default)
(deny network-outbound (with send-signal SIGKILL))
(allow network-outbound (remote ip "localhost:*"))
(deny network-bind)
(allow network-bind (local ip "localhost:*"))

this is the profile every Next.js process in this course runs under. sandbox-exec is deprecated by Apple but works on this machine, and the build refuses to run without it.

Every panel here finished, and none was killed, so none made a non-loopback connection. That covers the compile (the native compiler), the telemetry setting, the build, and a running next start and next dev. Lessons 24 and 25 add images and local fonts to the same proof. The driver scripts and the data server run under the stricter tools/sandbox.sb, which refuses instead of killing.

Newsletter

New writing, when it lands.

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