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