Skip to main content
Next.js · lesson 1 of 11
Next.js beginner 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

nextjs app-router routing

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.

Frequently Asked Questions

What does the ○ marker next to a route mean in a Next.js build?
○ marks a Static route — Next.js rendered it to plain HTML once, at build time, because nothing in it reads request-specific data. The build's own output names it: "○ (Static) prerendered as static content." Most routes in this lesson get ○; one route reads a real request header and gets a different marker, ◐, covered later in this lesson.
Why does /_not-found show up in the route table when I never created a not-found.tsx file?
The App Router always reserves a 404 handler for an app and compiles a default one automatically if app/not-found.tsx is missing. It gets built and listed in the route table exactly like any other route, which is why it appears even though no file for it was written.
Does a layout.tsx file create its own route?
No. Only a page.tsx file makes a URL segment addressable. layout.tsx wraps the page (and any nested layouts and pages) inside its folder, but it never appears as its own row in the build's route table — adding a layout to a folder that already has a page doesn't add a new entry.
What is a route group, the folder name in parentheses, for?
Parentheses around a folder name, like (marketing), tell the App Router to use that folder only for organizing files — grouping related routes under a shared layout, say — without adding a segment to the URL. app/(marketing)/pricing/page.tsx still resolves to /pricing, not /marketing/pricing. Two groups can define pages at different final paths with no conflict, but if two groups both resolve to the same final path, the build fails — the App Router still needs exactly one page per URL.
What does the ◐ marker mean, and why did only one route get it in this lesson?
◐ is a Partial Prerender: the build still renders the route's static shell to HTML up front, but whatever is wrapped in a <Suspense> boundary and reads real request data is left out of that static output and streams in on each actual request instead. Every other route in this lesson reads nothing request-specific, so the build can finish prerendering them completely and marks them ○; the one route that calls headers() gets ◐ instead.