Emmanuel Akinfulubi

Routing all 4 written

Folders are routes: pages and layouts, dynamic segments and route groups, linking, and the loading, error and not-found files.

4 Pages and layouts

The folders under app/ are the addresses of the site. A folder with a page.js is a page; a layout.js wraps the pages beneath it. That is all the routing there is to learn first.

A page is a file named page

A page is a component that is the default export of a file called page.js. The folder it sits in is the address: app/about/page.js answers /about, and app/dashboard/settings/page.js answers /dashboard/settings. A folder without a page.js is not an address, however many files it holds.

A layout wraps, and layouts nest

A layout is a component that takes children (the React course covers that prop) and returns the shell around them. The root layout, app/layout.js, is required and must contain the html and body tags, as the installation guide inside the vendored package puts it. A layout in a deeper folder wraps only the pages below it, and sits inside the layouts above it. The project has one: app/dashboard/layout.js.

Three addresses, two layouts

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/dashboard/layout.js

export default function DashboardLayout({ children }) {
  return (
    <section data-layout="dashboard">
      <nav>Dashboard menu</nav>
      {children}
    </section>
  );
}

app/dashboard/settings/page.js

export default function Settings() {
  return (
    <main data-page="settings">
      <h1>Settings</h1>
    </main>
  );
}

layouts.mjs

const base = process.env.BASE_URL;
const get = async (path) => (await fetch(base + path)).text();

const settings = await get('/dashboard/settings');
const dashboard = await get('/dashboard');
const about = await get('/about');

console.log(settings.match(/<div data-layout="root">.*?<\/main>/)[0]);

const at = (text, needle) => text.indexOf(needle);
const nested =
  at(settings, 'data-layout="root"') < at(settings, 'data-layout="dashboard"') &&
  at(settings, 'data-layout="dashboard"') < at(settings, 'data-page="settings"');
console.log('root, then dashboard, then the page:', nested);

check('the root layout wraps the dashboard layout, which wraps the page', nested, true);
check('both dashboard pages sit inside the dashboard layout', [dashboard, settings].map((h) => h.includes('data-layout="dashboard"')), [true, true]);
check('a page outside the folder does not', about.includes('data-layout="dashboard"'), false);
check('but every page sits inside the root layout', [dashboard, settings, about].map((h) => h.includes('data-layout="root"')), [true, true, true]);
check('and the title the root layout set is on all of them', [dashboard, settings, about].map((h) => h.includes('<title>Shelf</title>')), [true, true, true]);

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

node layouts.mjs

<div data-layout="root"><section data-layout="dashboard"><nav>Dashboard menu</nav><main data-page="settings"><h1>Settings</h1></main>
root, then dashboard, then the page: true
ok   the root layout wraps the dashboard layout, which wraps the page
ok   both dashboard pages sit inside the dashboard layout
ok   a page outside the folder does not
ok   but every page sits inside the root layout
ok   and the title the root layout set is on all of them

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 dashboard menu is written once, in the layout, and appears on both dashboard pages. The root layout's wrapper is on all three pages, and /about has no dashboard wrapper because its folder is not under dashboard. The title that the root layout exports as metadata became a <title> tag in each page's head; lesson 23 covers metadata.

A layout is not remounted; a template is

When a visitor moves between two pages that share a layout, React keeps the layout's component instance, so any state inside it survives. A file called template.js, placed where a layout would be, wraps its children the same way but gets a fresh instance on each navigation. The difference is state in the browser, so it cannot be seen from a driver that makes requests; the panel is a listing.

app/dashboard/template.js

shown, not run
export default function Template({ children }) {
  return <div>{children}</div>;
}

the difference between a layout and a template is whether state survives a navigation in a browser. The drivers in this course make requests and do not click, so it is not run.

5 Dynamic routes and route groups

A folder with a page.js is one address. When the address has a part that changes, such as the title of a book, the folder name says so with square brackets. This lesson covers the three bracket forms, then two ways to organise folders without changing any address.

One variable segment: [slug]

The folder app/books/[slug] matches /books/dracula and /books/persuasion. The text that matched arrives in the page as params.slug. In Next.js 16 params is a Promise, so a page that reads it is an async function that awaits it, the same await the Node.js course covers. The panel's page also records typeof params.then so that the claim is read off the running server and not taken from the documentation.

A page for a variable address can be built ahead of time. generateStaticParams returns the values to build, one object per page, and next build renders each of them to an HTML file. dynamicParams = false says that any other value is not rendered on demand but is a 404. The books come from an ordinary array in app/_lib/books.js, mapped with the key prop that the React course explains.

Three kinds of variable segment

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

app/books/[slug]/page.js

import { BOOKS, findBook } from '../../_lib/books.js';

export const dynamicParams = false;

export function generateStaticParams() {
  return BOOKS.map((book) => ({ slug: book.slug }));
}

export default async function Book({ params }) {
  const { slug } = await params;
  const book = findBook(slug);
  return (
    <main data-page="book">
      <h1>{book.title}</h1>
      <p>by {book.author}</p>
    </main>
  );
}

app/docs/[...parts]/page.js

export default async function Docs({ params }) {
  const isThenable = typeof params.then;
  const { parts } = await params;
  return (
    <main data-page="docs">
      <h1>Docs</h1>
      <p data-params-then={isThenable} data-kind={Array.isArray(parts) ? 'array' : typeof parts} data-parts={String(parts)}>
        {parts.length} segment(s): {parts.join(' / ')}
      </p>
    </main>
  );
}

app/guide/[[...parts]]/page.js

export default async function Guide({ params }) {
  const { parts } = await params;
  return (
    <main data-page="guide">
      <h1>Guide</h1>
      <p data-kind={Array.isArray(parts) ? 'array' : typeof parts} data-parts={String(parts)}>
        {parts ? parts.join(' / ') : 'the top of the guide'}
      </p>
    </main>
  );
}

segments.mjs

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

const base = process.env.BASE_URL;
const root = process.env.APP_DIR + '/.next';
const get = async (path) => {
  const res = await fetch(base + path);
  return { status: res.status, html: await res.text() };
};
const attr = (html, name) => new RegExp(name + '="([^"]*)"').exec(html)?.[1];

const dracula = await get('/books/dracula');
const nope = await get('/books/nope');
const deep = await get('/docs/a/b/c');
const bare = await get('/docs');
const top = await get('/guide');
const guideDeep = await get('/guide/x/y');

console.log('/books/dracula', dracula.status, dracula.html.match(/<h1>.*?<\/h1>/)[0]);
console.log('/books/nope   ', nope.status);
console.log('/docs/a/b/c   ', deep.status, 'parts:', attr(deep.html, 'data-kind'), attr(deep.html, 'data-parts'));
console.log('/docs         ', bare.status);
console.log('/guide        ', top.status, 'parts:', attr(top.html, 'data-kind'));
console.log('/guide/x/y    ', guideDeep.status, 'parts:', attr(guideDeep.html, 'data-kind'), attr(guideDeep.html, 'data-parts'));

const built = readdirSync(root + '/server/app/books').filter((f) => f.endsWith('.html')).sort();
const manifest = JSON.parse(readFileSync(root + '/prerender-manifest.json', 'utf8'));
console.log('built at build time:', built.join(' '));

check('a slug that generateStaticParams listed renders its book', [dracula.status, dracula.html.includes('<h1>Dracula</h1>')], [200, true]);
check('params is a Promise in Next.js 16', attr(deep.html, 'data-params-then'), 'function');
check('the build wrote one HTML file per slug', built, ['dracula.html', 'middlemarch.html', 'persuasion.html']);
check('dynamicParams = false is recorded as no fallback', manifest.dynamicRoutes['/books/[slug]'].fallback, false);
check('any other slug is a 404', nope.status, 404);
check('[...parts] gets an array of every segment', [attr(deep.html, 'data-kind'), attr(deep.html, 'data-parts')], ['array', 'a,b,c']);
check('and needs at least one segment', bare.status, 404);
check('[[...parts]] also matches the folder itself, with parts undefined', [top.status, attr(top.html, 'data-kind')], [200, 'undefined']);
check('and any depth after it', [guideDeep.status, attr(guideDeep.html, 'data-kind'), attr(guideDeep.html, 'data-parts')], [200, 'array', 'x,y']);

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

node segments.mjs

/books/dracula 200 <h1>Dracula</h1>
/books/nope    404
/docs/a/b/c    200 parts: array a,b,c
/docs          404
/guide         200 parts: undefined
/guide/x/y     200 parts: array x,y
built at build time: dracula.html middlemarch.html persuasion.html
ok   a slug that generateStaticParams listed renders its book
ok   params is a Promise in Next.js 16
ok   the build wrote one HTML file per slug
ok   dynamicParams = false is recorded as no fallback
ok   any other slug is a 404
ok   [...parts] gets an array of every segment
ok   and needs at least one segment
ok   [[...parts]] also matches the folder itself, with parts undefined
ok   and any depth after it

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 three book pages are HTML files in .next before any request arrives, and fallback: false in the prerender manifest is how dynamicParams = false shows up there. The other two forms differ in one thing: [...parts] needs at least one segment, so /docs is a 404, while [[...parts]] (double brackets) also matches its own folder and then parts is undefined. Neither folder sets generateStaticParams, so those pages are rendered when a request arrives.

Folders that are not part of the address

A folder in parentheses is a route group. It exists to share a layout or to keep files together, and its name is not in the address: app/(marketing)/pricing/page.js answers /pricing. A folder whose name starts with an underscore is a private folder: nothing inside it can be reached, even a file called page.js. The project keeps its shared code in app/_lib for that reason, and has a page in app/_drafts that no address leads to.

A route group and a private folder

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

app/(marketing)/layout.js

export default function MarketingLayout({ children }) {
  return <div data-layout="marketing">{children}</div>;
}

app/(marketing)/pricing/page.js

export default function Pricing() {
  return (
    <main data-page="pricing">
      <h1>Pricing</h1>
      <p>This file is at app/(marketing)/pricing/page.js and the address is /pricing.</p>
    </main>
  );
}

app/_drafts/page.js

export default function Drafts() {
  return <main data-page="drafts">Drafts</main>;
}

groups.mjs

const base = process.env.BASE_URL;
const get = async (path) => {
  const res = await fetch(base + path);
  return { status: res.status, html: await res.text() };
};

const pricing = await get('/pricing');
const literal = await get('/%28marketing%29/pricing');
const drafts = await get('/_drafts');

console.log('/pricing                ', pricing.status, pricing.html.match(/<div data-layout="root">.*?<\/main>/)[0]);
console.log('/(marketing)/pricing    ', literal.status);
console.log('/_drafts                ', drafts.status);

const at = (needle) => pricing.html.indexOf(needle);
check('the group name is not in the address', pricing.status, 200);
check('the group layout sits inside the root layout and around the page', at('data-layout="root"') < at('data-layout="marketing"') && at('data-layout="marketing"') < at('data-page="pricing"'), true);
check('the group name is not an address either', literal.status, 404);
check('a page.js in a private folder is not reachable', drafts.status, 404);

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

node groups.mjs

/pricing                 200 <div data-layout="root"><div data-layout="marketing"><main data-page="pricing"><h1>Pricing</h1><p>This file is at app/(marketing)/pricing/page.js and the address is /pricing.</p></main>
/(marketing)/pricing     404
/_drafts                 404
ok   the group name is not in the address
ok   the group layout sits inside the root layout and around the page
ok   the group name is not an address either
ok   a page.js in a private folder is not reachable

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 the route table shows them

The table next build prints (lesson 29 reads it line by line) lists addresses, not folders. The parent /books/[slug] has no symbol of its own, and each page it generated is listed under it with ●. The two catch-all routes are ƒ, rendered on each request. The group's name and the private folder are absent. This is the same single build as in lesson 3, shown again with the lines this lesson is about checked.

The route table with dynamic segments

ran at build time · next build · Next 16.3.1

patterns.mjs

import { readFileSync } from 'node:fs';

const manifest = JSON.parse(readFileSync(process.env.APP_DIR + '/.next/routes-manifest.json', 'utf8'));
const patterns = manifest.dynamicRoutes.map((route) => route.page);
console.log('dynamic route patterns:', patterns.join(' '));

check('the three patterns of this lesson are in the routes manifest as written', ['/books/[slug]', '/docs/[...parts]', '/guide/[[...parts]]'].map((p) => patterns.includes(p)), [true, true, true]);
check('no pattern contains a group or a private folder', patterns.some((p) => p.includes('(') || p.includes('_drafts')), 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 "├   /books/[slug]"
ok   the log shows "● /books/dracula"
ok   the log shows "● /books/persuasion"
ok   the log shows "ƒ /docs/[...parts]"
ok   the log shows "ƒ /guide/[[...parts]]"
ok   the log shows "○ /pricing"
ok   the log does not show "/_drafts"
ok   the log does not show "(marketing)"
dynamic route patterns: /api/books/[id] /authors/[name] /books/[slug] /docs/[...parts] /guide/[[...parts]] /meta/[slug]
ok   the three patterns of this lesson are in the routes manifest as written
ok   no pattern contains a group or a private folder

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

6 Linking and navigating

Pages link to each other with the Link component from next/link. In the HTML it is a plain anchor; the difference shows up in the browser, where Next.js fetches the next page's data instead of loading a whole new document.

Link is an anchor

A Link takes an href and renders <a href>, so links work with JavaScript off, in a crawler, and for a visitor who opens one in a new tab. The menu in app/books/layout.js uses a small client component, NavLink, that asks usePathname for the current address and marks the link that matches. usePathname is a hook, and hooks need state that lives in the browser, so the file starts with "use client" (lesson 9 explains the boundary; the rules of hooks are the React course's). The server still renders that component once for the HTML, with the right pathname.

What Link and usePathname produce

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

app/books/page.js

import Link from 'next/link';
import { BOOKS } from '../_lib/books.js';

export default function Books() {
  return (
    <main data-page="books">
      <h1>Books</h1>
      <ul>
        {BOOKS.map((book) => (
          <li key={book.slug}>
            <Link href={`/books/${book.slug}`}>{book.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

app/books/NavLink.js

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function NavLink({ href, children }) {
  const pathname = usePathname();
  return (
    <Link href={href} aria-current={pathname === href ? 'page' : undefined}>
      {children}
    </Link>
  );
}

links.mjs

const base = process.env.BASE_URL;
const list = await (await fetch(base + '/books')).text();
const one = await (await fetch(base + '/books/dracula')).text();

const link = (html, href) => new RegExp('<a [^>]*href="' + href + '"[^>]*>[^<]*</a>').exec(html)?.[0];
console.log(link(list, '/books/dracula'));
console.log('on /books:         ', link(list, '/books'));
console.log('on /books/dracula: ', link(one, '/books'));

check('each book is an ordinary anchor with an href', link(list, '/books/dracula'), '<a href="/books/dracula">Dracula</a>');
check('the link to the current address is marked', link(list, '/books').includes('aria-current="page"'), true);
check('the other menu link is not', link(list, '/pricing').includes('aria-current'), false);
check('on another address the same link is not marked', link(one, '/books').includes('aria-current'), false);

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

node links.mjs

<a href="/books/dracula">Dracula</a>
on /books:          <a aria-current="page" href="/books">Books</a>
on /books/dracula:  <a href="/books">Books</a>
ok   each book is an ordinary anchor with an href
ok   the link to the current address is marked
ok   the other menu link is not
ok   on another address the same link is not marked

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.

What the browser asks for when you click

When the router moves to another page it does not request the HTML. It requests the same address with a header, RSC: 1, and the server answers with the page's payload: the description of what the server components rendered, in the format React uses to send them (lesson 9 shows that format inside the HTML too). The router also adds an _rsc query parameter to these requests, a cache-busting value computed from its other headers (the code is next/dist/client/components/router-reducer/set-cache-busting-search-param.js). A request that has the header and not the parameter is redirected once to the address with it, which is why the driver follows a redirect here. Vary lists rsc, so a cache keeps the HTML and the payload of one address apart.

The same address, asked the way the router asks

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

payload.mjs

const base = process.env.BASE_URL;

const html = await fetch(base + '/books/dracula');
const rsc = await fetch(base + '/books/dracula', { headers: { RSC: '1' } });
const payload = await rsc.text();

console.log('html:   ', html.status, html.headers.get('content-type'));
console.log('payload:', rsc.status, rsc.headers.get('content-type'), '(redirected:', rsc.redirected + ')');
console.log('payload ends up at', new URL(rsc.url).pathname + new URL(rsc.url).search);
console.log('vary:', rsc.headers.get('vary').split(', ').slice(0, 2).join(', '), '...');

check('the same address answers HTML without the header', html.headers.get('content-type'), 'text/html; charset=utf-8');
check('and the payload with it', rsc.headers.get('content-type'), 'text/x-component');
check('after one redirect to an address with _rsc', [rsc.redirected, new URL(rsc.url).searchParams.has('_rsc')], [true, true]);
check('the payload is not an HTML document', payload.startsWith('<!DOCTYPE'), false);
check('but it holds the page content', payload.includes('Dracula'), true);
check('a cache is told to keep the two apart', rsc.headers.get('vary').split(', ').includes('rsc'), true);

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

node payload.mjs

html:    200 text/html; charset=utf-8
payload: 200 text/x-component (redirected: true)
payload ends up at /books/dracula?_rsc
vary: rsc, next-router-state-tree ...
ok   the same address answers HTML without the header
ok   and the payload with it
ok   after one redirect to an address with _rsc
ok   the payload is not an HTML document
ok   but it holds the page content
ok   a cache is told to keep the two apart

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.

Redirects

redirect() from next/navigation stops the render and sends the visitor elsewhere. In a page the status is 307 (temporary); permanentRedirect gives 308. The driver passes redirect: 'manual' to fetch, as the Node.js course shows, so that it receives the redirect itself and does not follow it. The two pages below call them with nothing else in the file. Because they read no request data, the build runs them once, and the redirect is stored in the build output: the .meta file next to the page holds the status and the location.

redirect and permanentRedirect

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

app/old-shelf/page.js

import { redirect } from 'next/navigation';

export default function OldShelf() {
  redirect('/books');
}

app/moved/page.js

import { permanentRedirect } from 'next/navigation';

export default function Moved() {
  permanentRedirect('/books');
}

redirects.mjs

import { readFileSync } from 'node:fs';

const base = process.env.BASE_URL;
const temporary = await fetch(base + '/old-shelf', { redirect: 'manual' });
const permanent = await fetch(base + '/moved', { redirect: 'manual' });
const followed = await fetch(base + '/old-shelf');
const meta = JSON.parse(readFileSync(process.env.APP_DIR + '/.next/server/app/old-shelf.meta', 'utf8'));

console.log('/old-shelf', temporary.status, 'location:', temporary.headers.get('location'));
console.log('/moved    ', permanent.status, 'location:', permanent.headers.get('location'));
console.log('followed  ', followed.status, new URL(followed.url).pathname);
console.log('.next/server/app/old-shelf.meta:', meta.status, meta.headers.location);

check('redirect() in a page is a 307 with a location', [temporary.status, temporary.headers.get('location')], [307, '/books']);
check('permanentRedirect() is a 308', [permanent.status, permanent.headers.get('location')], [308, '/books']);
check('a client that follows ends up on the target', [followed.status, new URL(followed.url).pathname], [200, '/books']);
check('the redirect was decided at build time and stored', [meta.status, meta.headers.location], [307, '/books']);

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

node redirects.mjs

/old-shelf 307 location: /books
/moved     308 location: /books
followed   200 /books
.next/server/app/old-shelf.meta: 307 /books
ok   redirect() in a page is a 307 with a location
ok   permanentRedirect() is a 308
ok   a client that follows ends up on the target
ok   the redirect was decided at build time and stored

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.

Navigating from code

To move the visitor from an event handler, a client component calls useRouter. It needs a browser to do anything, so this one is only shown.

app/books/BackButton.js

shown, not run
'use client';
import { useRouter } from 'next/navigation';

export default function BackButton() {
  const router = useRouter();
  return <button onClick={() => router.push('/books')}>Back to the books</button>;
}

router.push runs in the browser after a click. The drivers in this course send requests and click nothing, so this is not run.

7 Loading, error and not-found UI

A page can be slow, can say there is nothing here, or can fail. Next.js has one special file name for each, and each file is the UI for that situation: the visitor sees it in place of the page.

loading.js: show something while the page waits

A page that waits for data (lesson 13) holds up the whole response, unless part of it is allowed to arrive later. loading.js in a folder wraps the page in a React Suspense boundary (the React course covers Suspense) and uses the file's component as the fallback. The server sends the layout and the fallback at once, and the page when it is ready. The page here waits 800 ms for the data server on every request; force-dynamic makes it run per request (lesson 11). The driver reads the response as it arrives, with the reader that fetch exposes (lesson 12 explains the format).

A page behind loading.js

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

app/reports/page.js

export const dynamic = 'force-dynamic';

export default async function Reports() {
  const res = await fetch(`${process.env.DATA_URL}/slow?ms=800`);
  const { waited } = await res.json();
  return (
    <main data-page="reports">
      <h1>Reports</h1>
      <p>The report arrived after {waited} ms of waiting.</p>
    </main>
  );
}

app/reports/loading.js

export default function Loading() {
  return <p data-state="loading">Loading the reports...</p>;
}

loading.mjs

const base = process.env.BASE_URL;
const res = await fetch(base + '/reports');
const reader = res.body.getReader();
const decoder = new TextDecoder();
const chunks = [];
const started = performance.now();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push({ at: performance.now() - started, text: decoder.decode(value, { stream: true }) });
}
const all = chunks.map((c) => c.text).join('');
const first = chunks[0].text;
const reportAt = chunks.findIndex((c) => c.text.includes('The report arrived'));

console.log('status', res.status, '- body arrives in pieces:', chunks.length > 1);
console.log('first piece has the loading UI:', first.includes('Loading the reports...'));
console.log('first piece has the report:    ', first.includes('The report arrived'));
console.log('the report arrives later, after a wait of more than 500 ms:', reportAt > 0 && chunks[reportAt].at - chunks[0].at > 500);

check('the response is a 200', res.status, 200);
check('the first piece holds the fallback from loading.js', first.includes('Loading the reports...'), true);
check('and not the page', [first.includes('The report arrived'), first.includes('<h1>Reports</h1>')], [false, false]);
check('the page arrives in a later piece', reportAt > 0, true);
check('which came after the 800 ms wait on the data server, not before', chunks[reportAt].at - chunks[0].at > 500, true);
check('React marked the boundary with a template it later swaps', all.includes('<template id="B:0">'), true);

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

node loading.mjs

status 200 - body arrives in pieces: true
first piece has the loading UI: true
first piece has the report:     false
the report arrives later, after a wait of more than 500 ms: true
ok   the response is a 200
ok   the first piece holds the fallback from loading.js
ok   and not the page
ok   the page arrives in a later piece
ok   which came after the 800 ms wait on the data server, not before
ok   React marked the boundary with a template it later swaps

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.

not-found: there is nothing here

Two things produce a 404. An address that matches no page falls through to app/not-found.js; the build already rendered that page, as the /_not-found line of lesson 3 showed. And a page can decide for itself: notFound() from next/navigation stops the render and shows the nearest not-found.js. The folder app/authors/[name] has its own, so a missing author gets that text and not the one from the root. The response has status 404 and a noindex robots tag, so search engines drop it.

One detail from the output is worth knowing. When notFound() is thrown while a request is rendering, Next.js sends a small recovery document (<html id="__next_error__">) with the real payload inside it, and the browser draws the not-found UI from that payload. The text is in the response, but not as ready-made HTML.

notFound() and an address with no page

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

app/authors/[name]/page.js

import { notFound } from 'next/navigation';
import { BOOKS } from '../../_lib/books.js';

export default async function Author({ params }) {
  const { name } = await params;
  const books = BOOKS.filter((book) => book.author.toLowerCase().replace(/ /g, '-') === name);
  if (books.length === 0) notFound();
  return (
    <main data-page="author">
      <h1>{books[0].author}</h1>
      <p>{books.map((book) => book.title).join(', ')}</p>
    </main>
  );
}

app/authors/[name]/not-found.js

export default function AuthorNotFound() {
  return <main data-page="author-not-found">We have no books by that author.</main>;
}

app/not-found.js

export default function NotFound() {
  return (
    <main data-page="not-found">
      <h1>Nothing on this shelf</h1>
      <p>No page answers this address.</p>
    </main>
  );
}

not-found.mjs

const base = process.env.BASE_URL;
const get = async (path) => {
  const res = await fetch(base + path);
  return { status: res.status, html: await res.text() };
};
/* The payload is a list of JSON string pieces inside script tags. */
const payload = (html) =>
  [...html.matchAll(/self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g)].map((m) => JSON.parse(m[1])).join('');

const known = await get('/authors/bram-stoker');
const unknown = await get('/authors/nobody');
const nowhere = await get('/nope');

console.log('/authors/bram-stoker', known.status);
console.log('/authors/nobody     ', unknown.status, unknown.html.match(/<meta name="robots"[^>]*>/)[0]);
console.log('/nope               ', nowhere.status, nowhere.html.match(/<h1>.*?<\/h1>/)[0]);

check('an author who exists is a 200', known.status, 200);
check('notFound() makes the response a 404', unknown.status, 404);
check('and tells search engines not to index it', unknown.html.includes('<meta name="robots" content="noindex"/>'), true);
check('the HTML is the recovery shell', unknown.html.includes('<html id="__next_error__">'), true);
check('the text of authors/[name]/not-found.js is in the payload', payload(unknown.html).includes('We have no books by that author.'), true);
check('an address with no page is a 404 as well', nowhere.status, 404);
check('with the text of app/not-found.js in the HTML itself', nowhere.html.includes('<h1>Nothing on this shelf</h1>'), true);

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

node not-found.mjs

/authors/bram-stoker 200
/authors/nobody      404 <meta name="robots" content="noindex"/>
/nope                404 <h1>Nothing on this shelf</h1>
ok   an author who exists is a 200
ok   notFound() makes the response a 404
ok   and tells search engines not to index it
ok   the HTML is the recovery shell
ok   the text of authors/[name]/not-found.js is in the payload
ok   an address with no page is a 404 as well
ok   with the text of app/not-found.js in the HTML itself

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.

error.js: something threw

When rendering throws, the nearest error.js is shown in place of the page. It is an error boundary, the idea from the React course, and the vendored documentation (next/dist/docs/01-app/03-api-reference/03-file-conventions/error.md) says two things about it that this course cannot show without a browser: it wraps the page and the files below it but not the layout above it in the same folder, and error boundaries must be Client Components. The file receives a reset function that tries the render again, which is a reason it has to run in the browser.

What the panel shows is the server's side. The page throws on every request. The status is 500, the response holds a digest, a number that names the error, and not the message. The message is in the log of next start, where only the person running the server sees it. That is a security feature: an error text can name a file, a query or a secret. The code of error.js is in a file the browser downloads, and the browser draws it.

A page that throws, behind error.js

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

app/flaky/page.js

export const dynamic = 'force-dynamic';

export default function Flaky() {
  throw new Error('the shelf fell over');
}

app/flaky/error.js

'use client';

export default function ErrorPage({ error, reset }) {
  return (
    <main data-page="flaky-error">
      <p>Something went wrong on this page.</p>
      <button onClick={() => reset()}>Try again</button>
    </main>
  );
}

error.mjs

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

const base = process.env.BASE_URL;
const res = await fetch(base + '/flaky');
const html = await res.text();
const root = process.env.APP_DIR + '/.next';
/* The payload is a list of JSON string pieces inside script tags. */
const payload = [...html.matchAll(/self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g)].map((m) => JSON.parse(m[1])).join('');
const chunks = readdirSync(root + '/static/chunks').filter((f) => f.endsWith('.js'));
const inClient = chunks.filter((f) => readFileSync(root + '/static/chunks/' + f, 'utf8').includes('Something went wrong on this page'));

console.log('status', res.status);
console.log('the payload has a digest:', /"digest":"\d+"/.test(payload));
console.log('the response has the message:', html.includes('the shelf fell over') || payload.includes('the shelf fell over'));
console.log('files in static/chunks holding the text of error.js:', inClient.length);

check('a page that throws is a 500', res.status, 500);
check('the browser is sent a digest', /"digest":"\d+"/.test(payload), true);
check('and not the message', html.includes('the shelf fell over') || payload.includes('the shelf fell over'), false);
check('the HTML is the recovery shell, since error.js draws in the browser', html.includes('<html id="__next_error__">'), true);
check('the text of error.js is in a client file', inClient.length >= 1, true);
check('Next.js builds a global error page even when the project has none', existsSync(root + '/server/app/_global-error.html'), true);

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

node error.mjs

status 500
the payload has a digest: true
the response has the message: false
files in static/chunks holding the text of error.js: 1
ok   a page that throws is a 500
ok   the browser is sent a digest
ok   and not the message
ok   the HTML is the recovery shell, since error.js draws in the browser
ok   the text of error.js is in a client file
ok   Next.js builds a global error page even when the project has none

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 log line is the other half: the driver declares that next start must print the shelf fell over while it runs, and the build fails if the server logs any other error that no panel declared. The stack frames under it name build output files, so they are not shown.

global-error.js: when the root layout fails

error.js sits inside the layout of its folder, so it cannot catch an error in the root layout itself. app/global-error.js replaces the whole document in that case, and so must write its own html and body. The last check above shows that Next.js builds a default one when you write none.

app/global-error.js

shown, not run
'use client';

export default function GlobalError({ reset }) {
  return (
    <html lang="en">
      <body>
        <h1>The shelf is unavailable</h1>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}

a global error page appears only when the root layout itself throws, and drawing it takes a browser. The project has no such file; the default one is checked above.

Newsletter

New writing, when it lands.

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