Next.js · lesson 1 of 11
The App Router and the Route Table
A real Next.js 16 build's route table: which folders become routes, why layouts don't add rows, and why one route gets a different marker.
By SkillByExample · Published
A Next.js app has almost no routing code to write — the files inside app/ are the
routes, and next build prints a table of exactly what it found. Every command below runs
inside Docker’s node:26.13 image, pinned so the Next.js and React versions quoted in this
lesson are the ones that actually produced the output shown, not a guess.
Docker here does two things: it pins the exact Node version so this lesson’s output is
reproducible, and it runs npm install and next build inside the same Linux container,
which avoids a real failure otherwise possible on a reader’s own machine — Next.js installs
a platform-specific native binary (@next/swc-*) for whatever OS the install actually runs
on, so installing on Windows/macOS and then building inside a Linux container would fail with
a missing-binary error. -v "$(pwd)":/root/next-app mounts the current directory into the
container at /root/next-app; -w makes that the working directory, so every file created
below lands on the host, not inside a container that vanishes when --rm cleans it up.
The second mount, -v nextjs-modules:/root/next-app/node_modules, puts node_modules in a
named Docker volume that lives inside Linux instead of on the bind-mounted host folder —
Docker Desktop’s file sharing between Windows/macOS and its Linux VM is slow with
node_modules’ thousands of small files, and the volume makes both the install and every
build several times faster; the rest of the project still lives in your own folder so you
can edit it. Running directly on Linux, or running npm/next locally with no Docker at
all, needs no such volume. Anyone with Node 26 installed locally can run the exact same
npm/next commands without Docker at all. On Git Bash for Windows, these exact
docker run commands fail as typed — Git Bash rewrites the absolute-looking
-w /root/next-app argument into a Windows path before Docker ever sees it — prefix each
command with MSYS_NO_PATHCONV=1 to stop that rewriting, or run them from PowerShell or WSL
instead.
$ docker run --rm node:26.13 node --version
v26.13.0
Every Folder With a page.tsx Becomes a Route
Four files set up the project before any route exists: package.json pins the dependencies,
next.config.ts turns on Cache Components (on by default in every new Next.js 16 app), and
tsconfig.json configures TypeScript, which is also the App Router’s default for a new project.
{
"name": "next-app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.4.3",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "20.19.43",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
export default nextConfig;
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
Three more files, inside app/, define an app with a home page and an about page: a root
layout every page is nested inside, and one page.tsx per route.
// app/layout.tsx — required; every page in the app renders inside this
export const metadata = {
title: "Next App",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
// app/page.tsx — the folder app/ itself maps to the root route, "/"
export default function HomePage() {
return <h1>Home</h1>;
}
// app/about/page.tsx — the folder name "about" becomes the URL segment "/about"
export default function AboutPage() {
return <h1>About</h1>;
}
npm install is not a step to hide — without it, nothing below can run on a reader’s own
machine, so it’s shown exactly as it runs. next build then compiles every route it finds
under app/ and prints a table of them, but its raw output also includes a page-generation
progress counter — how many of N pages are done, redrawn in place as the build goes, whose
worker count depends on how many CPUs the machine running it has, and whose timing differs
every run. Even the line that reports success, which starts with ✓ Generating static pages using, repeats that same unstable text, so the command below greps every line containing it
out. Nothing else is touched — everything that remains below is exactly what the build
printed:
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund
added 28 packages in 35s
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 86ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 5.4s
Running TypeScript ...
Finished TypeScript in 2.7s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /about
○ (Static) prerendered as static content
Two files inside app/ produced three rows. / and /about came from the page.tsx
files, but /_not-found appeared with no file written for it at all — the App Router always
compiles a default 404 handler for every app, and it shows up in the table like any other
route. - Cache Components enabled and - Partial Prefetching enabled are new lines this
build prints that a Next.js 15 project wouldn’t — they report that next.config.ts’s two
settings above are actually active. Every row still got the same ○ marker, and the build
explains exactly what that means right under the table: (Static) prerendered as static content. Nothing here reads a cookie, a header, or request data, so Next.js rendered all
three to plain HTML once, at build time.
Nested Layouts Wrap Pages; They Don’t Add Routes
Add a /dashboard route with its own layout — a <nav> wrapped around just that one page,
not the whole app:
// app/dashboard/page.tsx
export default function DashboardPage() {
return <h1>Dashboard</h1>;
}
// app/dashboard/layout.tsx — wraps only app/dashboard/page.tsx and anything nested under it
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<nav>Dashboard nav</nav>
{children}
</section>
);
}
Before building, predict how many rows the table grows by — one new file has a page.tsx,
the other doesn’t.
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 120ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.1s
Running TypeScript ...
Finished TypeScript in 3.3s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└ ○ /dashboard
○ (Static) prerendered as static content
Exactly one row, not two — app/dashboard/layout.tsx never appears in the table on its own.
A layout.tsx wraps whatever page.tsx shares its folder (and anything nested deeper
inside it), but it is not itself a route; only page.tsx makes a segment addressable.
app/layout.tsx, the root layout written in the previous section, is the same idea scaled
up to the whole app — it wraps every single row in this table, including /_not-found, and
it still doesn’t get a row of its own.
Route Groups: Parentheses Vanish From the URL
Wrap a new page in a folder named (marketing) — parentheses included:
// app/(marketing)/pricing/page.tsx
export default function PricingPage() {
return <h1>Pricing</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 109ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.3s
Running TypeScript ...
Finished TypeScript in 3.1s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /dashboard
└ ○ /pricing
○ (Static) prerendered as static content
The new row reads /pricing, not /marketing/pricing or /(marketing)/pricing — the
folder name in parentheses never reaches the URL at all. A route group exists purely for
organizing files on disk (grouping routes that should share one layout, say, without that
layout applying to the rest of the app); the App Router strips it out when it builds the
path.
Not Every Route Gets ○
Every row so far has been ○, because cacheComponents: true only matters once a route
actually reads something request-specific — and none has, yet. Add one that does: read the
incoming request’s user-agent header with headers(), wrapped in a <Suspense> boundary.
// app/profile/page.tsx
import { Suspense } from "react";
import { headers } from "next/headers";
async function UserAgent() {
const requestHeaders = await headers();
return <p>{requestHeaders.get("user-agent")}</p>;
}
export default function ProfilePage() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserAgent />
</Suspense>
);
}
Before building, predict whether /profile gets the same ○ every other row in this lesson
has gotten so far, now that it reads something real from the actual request.
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 102ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 6.2s
Running TypeScript ...
Finished TypeScript in 3.9s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /dashboard
├ ○ /pricing
└ ◐ /profile
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/profile gets ◐, not ○ — and the legend under the table grows a second line to explain
it: (Partial Prerender) prerendered as static HTML with dynamic server-streamed content.
The build still prerenders a static shell for the route, but the part wrapped in
<Suspense> that calls headers() is excluded from that static output and runs again on
every real request instead. What happens if that <Suspense> boundary is missing entirely —
Cache Components doesn’t silently fall back to dynamic rendering — is
Dynamic by Default: Rendering Modes’s subject.
Practice
Each task below builds on the files the previous ones (and the main lesson above) left in place — do them in order.
1. Add a top-level /contact page (no route group) and predict which row it lands on, alphabetically, before building.
Solution
// app/contact/page.tsx
export default function ContactPage() {
return <h1>Contact</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 112ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 5.5s
Running TypeScript ...
Finished TypeScript in 4.3s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /contact
├ ○ /dashboard
├ ○ /pricing
└ ◐ /profile
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/contact lands right after /about and before /dashboard, still alphabetically — the
table sorts by final URL path, with no regard for which route was added last or how deep its
folder sits.
2. Add a folder with only a layout.tsx and no page.tsx, and confirm it produces no row at all.
Solution
// app/empty/layout.tsx — no page.tsx in this folder
export default function EmptyLayout({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 123ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.1s
Running TypeScript ...
Finished TypeScript in 2.9s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /contact
├ ○ /dashboard
├ ○ /pricing
└ ◐ /profile
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
No /empty — a layout with nothing to wrap doesn’t become reachable on its own. The route
table is built entirely from where page.tsx files live; layout.tsx only changes what
wraps a route that some other file already made addressable.
3. Delete app/about/page.tsx and confirm /about disappears from the table.
Solution
$ rm app/about/page.tsx
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 85ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.2s
Running TypeScript ...
Finished TypeScript in 3.9s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /contact
├ ○ /dashboard
├ ○ /pricing
└ ◐ /profile
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/about is gone now — the route table is recomputed from the files on disk every build, not
accumulated across builds. Removing a page.tsx removes its route exactly as directly as
adding one created it.
4. Add a second route group, (shop), with its own page, and confirm two differently named groups can coexist.
Solution
// app/(shop)/cart/page.tsx
export default function CartPage() {
return <h1>Cart</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build 2>&1 | grep -v -E "Generating static pages using|Collecting page data using"
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 98ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 4.4s
Running TypeScript ...
Finished TypeScript in 3.6s ...
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /cart
├ ○ /contact
├ ○ /dashboard
├ ○ /pricing
└ ◐ /profile
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
/cart sits alongside /pricing with no trace of either group’s name — (marketing) and
(shop) are both stripped out the same way. Route groups only have to be unique as folder
names on disk, and two groups coexist fine as long as the final paths their pages resolve
to stay different — which is exactly what the next task breaks on purpose.
5. Add app/(shop)/pricing/page.tsx — the same final path, /pricing, that
app/(marketing)/pricing/page.tsx already resolves to. Predict whether the build still
succeeds now that two groups define a page for the same path.
Solution
// app/(shop)/pricing/page.tsx — resolves to /pricing, same as app/(marketing)/pricing/page.tsx
export default function ShopPricingPage() {
return <h1>Pricing (shop)</h1>;
}
$ docker run --rm -v "$(pwd)":/root/next-app -v nextjs-modules:/root/next-app/node_modules -w /root/next-app -e NEXT_TELEMETRY_DISABLED=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npx next build
▲ Next.js 16.4.3 (Turbopack)
✓ Running next.config.ts took 83ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
> Build error occurred
Error: Turbopack build failed with 1 error:
./app/(shop)
Error: You cannot have two parallel pages that resolve to the same path. Please check /(marketing)/pricing and /(shop).
at ignore-listed frames
The build fails. Route groups only strip their own name from the URL — they never make two
pages that land on the same final path coexist. Deleting either page.tsx (or moving one
page to a path that doesn’t collide) is the fix; the error itself names both conflicting
folders so there’s no guessing which two files are at fault.