Routing Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and TypeScript 5.1 or later. It teaches the App Router (the app/ directory) first and puts the Pages Router (the pages/ directory) and React Router next to it. Routing itself does not depend on the caching model, but two details do: reading params or a routing hook such as usePathname() while a route is prerendered. The examples use the previous model (cacheComponents off, the default), and a section near the end shows the small changes that the opt-in Cache Components model asks for. Every example was built with Next.js 16.3.8, Node.js 24.16 and npm 11.13 in a scratch copy of the Bookshelf — the catalogue of books, authors and reviews that the rest of this section grows, and that the Django and NestJS sections also build as a back end — and run with next start plus curl and a real browser; each one is followed by the official page it derives from.

Routing answers one question: given a URL, which components does the application render? Everything else in the section hangs from the answer, because in Next.js the route is also the unit of rendering, of code splitting, of caching and of data loading. This page explains the idea from first principles, shows how a React single-page app does it by hand, and then shows how Next.js moves the same decision into the folder structure and into the server. It does not re-teach React; for components, props and state read React Getting Started; for the library-level view of client routing read React Routing. The architecture behind the request, the RSC payload and the HTML is in Introduction and Architecture, and the folder conventions that surround app/ are in Project Structure. The special files that sit next to page are explained in Layouts, Templates and Special Files; this page only traces where template, loading and error land in the tree of a URL. The dynamic segment shapes are covered in depth by Dynamic Routes and Route Groups, the Link API, prefetching and the client cache by Linking and Navigation, and the advanced route types (parallel and intercepting routes, Route Handlers) by later pages of this section.

How Routing Works in a React Single-Page App

React renders a tree of components from state. It has no notion of a URL. A single-page app (SPA) therefore adds three things on top of React, and every client-side router is a variation of them:

  1. The browser’s History API to change the address bar without asking the server for a new document.

  2. A route table that matches the current URL (location.pathname) against patterns such as /books/:id.

  3. React state that holds the current location, so that a change re-renders the matching components.

Here is the whole mechanism without any library. It is not production code (no nested routes, no scroll handling, no data loading), but it is exactly what a router does:

A router in a few lines of plain TypeScript (the same code without types ran in a browser console)
const routes: Record<string, string> = { "/": "Home", "/books": "Books" };

function match(pathname: string): string {
  const m = pathname.match(/^\/books\/([^/]+)$/); // "/books/:id"
  return m ? `Book ${m[1]}` : (routes[pathname] ?? "Not found");
}

function render() {
  console.log("render:", match(location.pathname)); // React would set state here
}

// 1. Our own navigation: change the URL, then render. No request is made.
function navigate(to: string) {
  history.pushState(null, "", to);
  render();
}

// 2. The browser's back and forward buttons: popstate fires, we render again.
window.addEventListener("popstate", render);

navigate("/books/7"); // render: Book 7
navigate("/books");   // render: Books
history.back();       // render: Book 7  (popstate)

Running this in a page returned Book 7, Books and Book 7 again after history.back(). Two details matter later. history.pushState() does not fire popstate; only the back and forward buttons do, so a router must render itself after its own pushState. And the server is out of the loop: the first visit loaded one HTML document, the app’s JavaScript bundle, and from then on every "page" is a component switch. A SPA host therefore has to answer every path with the same index.html (a "fallback"), because /books/7 is not a file, and the browser asks for it when the user reloads or opens a shared link.

React Router packages the three pieces. This is the same Bookshelf shape as a declarative route object with a nested layout:

React Router: the route table is data, nesting is children and Outlet
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import {
  createBrowserRouter,
  Link,
  Outlet,
  RouterProvider,
  useParams,
} from "react-router";

function BooksLayout() {
  return (
    <section>
      <nav>
        <Link to="/books/1">Dune</Link>
      </nav>
      <Outlet /> {/* the matching child route renders here */}
    </section>
  );
}

function Book() {
  const { id } = useParams(); // string | undefined
  return <h1>Book {id}</h1>;
}

const router = createBrowserRouter([
  { path: "/", element: <h1>Bookshelf</h1> },
  {
    path: "/books",
    element: <BooksLayout />,
    children: [
      { index: true, element: <h1>All books</h1> },
      { path: ":id", element: <Book /> },
    ],
  },
]);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
);

This file type-checks with tsc --strict against react-router 8.4.0 (the version on npm on 2026-10-03; the older react-router-dom package, which React Routing imports from, is a separate package that re-exports the same API, at 7.18.4 on the same date). Source: React Router: Routing

A SPA router has real strengths: instant transitions once the bundle is loaded, full control in JavaScript, and a static host is enough. It also has the weaknesses that motivate a framework router:

  • Nothing is rendered until the bundle runs. The first HTML is an empty shell; the content appears after the JavaScript has downloaded, executed and (usually) fetched data. Search engines and slow devices feel it.

  • The route table is code you maintain by hand, in a place that is not obviously connected to the files that implement each screen.

  • Code splitting is opt-in. Without lazy() per route the bundle contains every screen.

  • Data loading is a separate problem, solved per library (loaders in React Router, useEffect, a query library) and usually serial: route, then component, then request.

What a Framework Router Changes

A framework router keeps the client half (History API, Link, nested layouts) and adds three decisions that a library cannot make, because it does not own the server and the build:

Table 1. A React SPA router versus the Next.js App Router
Question React SPA with a router library Next.js App Router

Where is the route table?

In code: an array of route objects or JSX <Route> elements.

In the file system: folders are URL segments, page files make them reachable. The build derives the table.

Who matches the URL first?

The browser, after the bundle has loaded.

The server, on every request. The matching route’s layouts and page render on the server as Server Components.

What does a navigation fetch?

Possibly a lazy chunk, then whatever data the component asks for.

The RSC payload of the next route (the serialised UI tree), requested with a ?_rsc= parameter, plus Client Component chunks that are not loaded yet.

What does the first visit return?

An empty HTML shell plus the bundle.

Complete HTML of the page, plus the RSC payload for hydration. See the SSR, SSG and hydration pages of this section.

Code splitting

Manual (React.lazy per route).

Automatic per route segment, and Server Components add no client JavaScript at all.

Layouts

Hand-written nesting with Outlet.

The layout file of each folder; nesting follows the folders and the layout is kept alive across navigations.

The consequence worth internalising is that Next.js routing is server-centric with client-side navigation. The server decides what a URL renders, in every case; the client just avoids reloading the document while it asks. The rest of the page follows that idea.

File-System Routing in the App Router

Next.js uses file-system based routing: the folders and files under app/ define the routes. Two kinds of names are involved.

  • Folders define route segments. A segment is one part of the URL path between slashes, so app/books/[id] contributes the two segments books and [id].

  • Files with reserved names define the UI shown for a segment. page is the one that makes a segment reachable, layout wraps the segment and its descendants, and route (a Route Handler, covered on its own page) answers with data instead of UI.

Table 2. The file conventions of this page
File Extensions Purpose

page

.js .jsx .tsx

The UI of a route. Default-export a React component. A segment is publicly accessible only if it contains a page (or a route).

layout

.js .jsx .tsx

UI shared by a segment and all segments below it. Receives children. The root layout in app/ is required and must render <html> and <body>.

loading, error, not-found, template, default, route

.js .jsx .tsx (route: .js .ts)

Other special files. Introduced in later pages of this section.

A route is made of segments, and the segment kinds are:

Table 3. Segment kinds and what they match
Folder Matches params

app/books/page.tsx

/books (a static segment)

none

app/books/[id]/page.tsx

/books/1, /books/2, …​ (one dynamic segment)

{ id: '1' }

app/shop/[...slug]/page.tsx

/shop/a, /shop/a/b, /shop/a/b/c, but not /shop (catch-all)

{ slug: ['a'] }, { slug: ['a', 'b'] }

app/shop/[[...slug]]/page.tsx

the same, and /shop (optional catch-all)

{ slug: undefined } for /shop

app/(marketing)/about/page.tsx

/about (the folder name in parentheses is a route group: organisation only, not part of the URL)

none

app/books/_components/

nothing (a leading underscore makes a private folder: opted out of routing)

none

Three consequences are easy to miss:

  • Only page and route make a segment public. Any other file in a route folder (a helper, a test, a component) is colocated safely: it can never be reached by URL, and only what page returns is sent to the client. A build with app/books/format.ts and app/books/_components/helper.ts added produced exactly the same route table as one without them. Private folders are therefore optional; they are useful to group files and to avoid clashes with future file conventions, which the project-structure page of this section discusses.

  • Route groups do not change the URL, but they do change the tree. (marketing)/about is /about. Two groups that resolve to the same URL ((marketing)/about and (shop)/about) are a build error.

  • Dynamic segments return strings (or arrays of strings). params.id is '2', never 2, and unknown params are not an error by themselves: whether /books/99 is a 404 is decided by your code (notFound()) or by dynamicParams for statically generated params.

Worked Example: the Bookshelf Route Tree

The Bookshelf uses four kinds of route: the home page, a static page in a route group, a layout with a list of books, and a nested dynamic route. The folder tree below is the whole application’s routing; the file contents follow.

The Bookshelf routing, as a folder tree
app/
├── layout.tsx                 # root layout: <html>, <body>, main navigation
├── page.tsx                   # /
├── (marketing)/
│   └── about/page.tsx         # /about   (the group is not in the URL)
└── books/
    ├── layout.tsx             # shared by /books and everything below it
    ├── page.tsx               # /books
    └── [id]/
        ├── page.tsx           # /books/2
        └── reviews/
            └── page.tsx       # /books/2/reviews

next build reads that tree and prints the routes it found. This is the output, from the scratch app with the final files below (● marks pages prerendered through generateStaticParams, ○ static pages):

The route table of next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
├ ○ /books
├   /books/[id]
│ ├ ● /books/1
│ ├ ● /books/2
│ └ ● /books/3
└   /books/[id]/reviews
  ├ ● /books/1/reviews
  ├ ● /books/2/reviews
  └ ● /books/3/reviews

○  (Static)  prerendered as static content
●  (SSG)     prerendered as static HTML (uses generateStaticParams)

The root layout holds what every page shares: the document and the navigation.

app/layout.tsx
import Link from "next/link";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>
            <Link href="/">Home</Link> | <Link href="/books">Books</Link> |{" "}
            <Link href="/about">About</Link>
          </nav>
        </header>
        {children}
      </body>
    </html>
  );
}

LayoutProps<"/"> (and PageProps<"/books/[id]"> below) are global type helpers: Next.js generates them (during next dev, next build or next typegen) from the route table, so params is typed from the folder names. They need no import. The configuration page of this section covers next typegen.

The books layout lists the books, and the two leaf pages are the dynamic part. params is a Promise in current Next.js (see What Changed Since Next.js 13 and 14), so the page awaits it.

app/books/layout.tsx
import { Suspense } from "react";
import { MountedAt } from "@/components/mounted-at";
import { BookNav } from "@/components/book-nav";
import { listBooks } from "@/lib/books";

export default async function BooksLayout({ children }: LayoutProps<"/books">) {
  const books = await listBooks();
  return (
    <section>
      <aside>
        <MountedAt label="books-layout" />
        <Suspense fallback={<p>Loading books...</p>}>
          <BookNav books={books} />
        </Suspense>
      </aside>
      <div>{children}</div>
    </section>
  );
}
app/books/[id]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { getBook, listBooks } from "@/lib/books";

export async function generateStaticParams() {
  return (await listBooks()).map((b) => ({ id: b.id }));
}

export default async function BookPage({ params }: PageProps<"/books/[id]">) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();
  return (
    <article>
      <h1>{book.title}</h1>
      <p>{book.author}</p>
      <Link href={`/books/${book.id}/reviews`}>Reviews</Link>
    </article>
  );
}
app/books/[id]/reviews/page.tsx
import { listBooks, listReviews } from "@/lib/books";

export async function generateStaticParams() {
  return (await listBooks()).map((b) => ({ id: b.id }));
}

export default async function ReviewsPage({
  params,
}: PageProps<"/books/[id]/reviews">) {
  const { id } = await params;
  const reviews = await listReviews(id);
  return <h2>{reviews.length} reviews for book {id}</h2>;
}

Two small Client Components finish the example. MountedAt is a probe, used in the navigation section below: it records the moment it was mounted. BookNav is the usual "highlight the active link" component, and the reason a layout can know which child is active even though layouts do not receive the URL.

components/mounted-at.tsx
"use client";

import { useEffect, useState } from "react";

// The effect runs once per mount: if the timestamp does not change during
// navigation, the component was kept alive (it was not remounted).
export function MountedAt({ label }: { label: string }) {
  const [at, setAt] = useState<string | null>(null);
  useEffect(() => setAt(new Date().toISOString()), []);
  return (
    <p data-testid={label}>
      {label} mounted at {at ?? "..."}
    </p>
  );
}
components/book-nav.tsx
"use client";

import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";

export function BookNav({ books }: { books: { id: string; title: string }[] }) {
  const active = useSelectedLayoutSegment(); // "2" on /books/2/reviews, null on /books
  return (
    <ul>
      {books.map((b) => (
        <li key={b.id}>
          <Link
            href={`/books/${b.id}`}
            aria-current={active === b.id ? "page" : undefined}
          >
            {b.title}
          </Link>
        </li>
      ))}
    </ul>
  );
}

Built and started with next start, the routes answered as follows:

Table 4. curl results against the production server
Request Status Why

/, /about, /books

200

Static pages. /about is served although its folder is (marketing)/about.

/books/2, /books/2/reviews

200

Prerendered through generateStaticParams.

/books/99

404

notFound() in the page; not in generateStaticParams, so it was rendered on request.

/books/2/reviews/extra

404

No segment extra exists below reviews.

/marketing/about

404

The route group is not in the URL.

URL to Component Tree

The diagram maps one request through the three views of the Bookshelf: the folders, the URL segments they stand for, and the component tree React renders.

A three-column diagram titled One route, three views. The left column shows the Bookshelf app folder with layout.tsx, page.tsx, a (marketing) route group with about/page.tsx, and a books folder containing layout.tsx, page.tsx and an [id] folder, which holds page.tsx and a reviews folder with its own page.tsx. The path app/layout.tsx, books, books/layout.tsx, [id], reviews and reviews/page.tsx is highlighted in blue for the URL /books/2/reviews. The middle column lists the URL segments of /books/2/reviews: the root segment, books, the dynamic segment 2 with params id equal to 2, and the leaf segment reviews, each linked to its folder. The right column shows nested boxes: RootLayout contains BooksLayout, which contains the sidebar Client Components and ReviewsPage, the leaf that receives params. A note says BookPage is not rendered for this URL because it is the leaf of /books/2, a sibling of reviews.

The mapping has a few rules that explain every behaviour in this page:

  1. Segments are matched left to right, one per folder. /books/2/reviews matches app, books, [id] and reviews.

  2. Each segment on the path contributes at most one layout, and the last segment contributes the page. Layouts nest from the root down; each receives the next level as children. The tree for /books/2/reviews is RootLayout, containing BooksLayout, containing ReviewsPage. There is no layout in [id] or reviews, so those segments add only a param and a page.

  3. A page is a leaf. books/[id]/page.tsx is the leaf of /books/2; it is not an ancestor of /books/2/reviews, even though its folder is. The two pages are siblings below the same layout.

  4. Params flow down. params is { id: '2' } for the page and, if it declared them, for layouts below the [id] segment. A layout above a dynamic segment does not see it.

  5. Layouts do not receive the URL. A layout is shared by many URLs, so it has no searchParams and no pathname. To react to the URL from a layout, use a Client Component with usePathname(), useSelectedLayoutSegment() or useSelectedLayoutSegments(), as BookNav does.

The same components, written as the JSX that Next.js assembles for you:

The tree of /books/2/reviews, written out (conceptual, not code you write)
<RootLayout>                         {/* app/layout.tsx */}
  <BooksLayout>                      {/* app/books/layout.tsx */}
    <ReviewsPage params={{ id: "2" }} />  {/* app/books/[id]/reviews/page.tsx */}
  </BooksLayout>
</RootLayout>

The tree above is the minimal one: only layouts and a page. Real applications put more files in a segment, and each of them adds a component around the next level. The next section traces two URLs through a Bookshelf that has them.

Tracing Two URLs Through the Full Bookshelf

This section follows the localised Bookshelf that later pages of this section build (the URLs carry a language, /en/books), with the special files that a real catalogue needs. It answers, file by file, three questions for two URLs: which folders match, which files each matched folder contributes, and which React tree comes out. The behaviour of each special file is explained in Layouts, Templates and Special Files; here we only need where each file lands.

The files

The localised Bookshelf used by the traces
app/
├── [lang]/
│   ├── layout.tsx            # root layout: <html lang>, <body>, main navigation
│   ├── page.tsx              # /en
│   ├── about/page.tsx        # /en/about
│   └── books/
│       ├── layout.tsx        # catalogue sidebar, kept across navigations
│       ├── template.tsx      # remounted when the child segment changes
│       ├── error.tsx         # error boundary for everything below books
│       ├── loading.tsx       # Suspense fallback for everything below books
│       ├── page.tsx          # /en/books
│       ├── new/page.tsx      # /en/books/new        (static, wins over [id])
│       ├── broken/page.tsx   # /en/books/broken     (throws on every request)
│       ├── [...rest]/page.tsx  # /en/books/a/b/c    (catch-all, loses to everything else)
│       └── [id]/
│           ├── page.tsx      # /en/books/42
│           └── reviews/
│               ├── loading.tsx  # Suspense fallback for the reviews page only
│               └── page.tsx     # /en/books/42/reviews  (rendered on every request)
└── api/books/route.ts        # /api/books (a Route Handler, JSON)

There is no app/layout.tsx: app/[lang]/layout.tsx renders <html> and <body>, so it is the root layout, and its lang param is available to every segment below it. Every layout, template, loading file, error file and page in the scratch app renders an element with a data-file attribute naming its own path, so that the rendered DOM can be compared with the folder tree. These are the files that the earlier example did not have:

app/[lang]/layout.tsx: a dynamic root layout that only accepts the known languages
import Link from "next/link";

export const dynamicParams = false; // /de/books is a 404, not a page in an unknown language

export function generateStaticParams() {
  return [{ lang: "en" }, { lang: "fr" }];
}

export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) {
  const { lang } = await params;
  return (
    <html lang={lang}>
      <body data-file="app/[lang]/layout.tsx">
        <nav>
          <Link href={`/${lang}`}>Home</Link> | <Link href={`/${lang}/books`}>Books</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}

With dynamicParams = false, /de/books answered 404; without it, a 200 page with lang="de", because a dynamic segment accepts any value by default. The export applies to the [lang] segment only: /en/books/99 was still rendered on request.

app/[lang]/books/template.tsx and error.tsx
// app/[lang]/books/template.tsx
import { MountedAt } from "@/components/mounted-at";

export default function BooksTemplate({ children }: { children: React.ReactNode }) {
  return (
    <div data-file="app/[lang]/books/template.tsx">
      <MountedAt label="books-template" />
      {children}
    </div>
  );
}

// app/[lang]/books/error.tsx
"use client";

export default function BooksError({
  error,
  retry,
}: {
  error: Error & { digest?: string };
  retry: () => void;
}) {
  return (
    <div data-file="app/[lang]/books/error.tsx">
      <p>Could not load this part of the catalogue: {error.message}</p>
      <button onClick={() => retry()}>Try again</button>
    </div>
  );
}

The two loading files return a <p> with their data-file marker and a loading message. MountedAt is the probe defined earlier on this page, extended with an <input> so that the traces can also tell whether typed text survives a navigation. The reviews page waits for a slow data source on every request:

app/[lang]/books/[id]/reviews/page.tsx
import { connection } from "next/server";
import { listReviews } from "@/lib/books";

export default async function ReviewsPage({ params }: PageProps<"/[lang]/books/[id]/reviews">) {
  await connection(); // render on every request, never at build time
  const { id } = await params;
  const reviews = await listReviews(id); // about 800 ms in the scratch app
  return (
    <main data-file="app/[lang]/books/[id]/reviews/page.tsx">
      {reviews.length} reviews for book {id}
    </main>
  );
}

Source: connection and page.js

Trace 1: /en/books, a nested static route

Table 5. Matching /en/books segment by segment
URL segment Folder that matches Files it contributes, outside in

(root)

app/

Nothing: there is no app/layout.tsx. The built-in not-found page is the fallback for URLs that match nothing.

en

app/[lang]/ (dynamic, params.lang = "en")

layout.tsx, the root layout.

books

app/[lang]/books/ (static)

layout.tsx, template.tsx, error.tsx, loading.tsx and, because this is the last segment, page.tsx.

The order inside one segment is fixed by the docs: layout, template, error, loading, not-found, then page or the next segment. The result, written as the JSX Next.js assembles (the boundary components are internal; their names here are descriptive):

The tree of /en/books (conceptual, not code you write)
<RootLayout params={/* { lang: "en" } */}>              {/* app/[lang]/layout.tsx */}
  <BooksLayout params={/* { lang: "en" } */}>           {/* app/[lang]/books/layout.tsx */}
    <BooksTemplate key={/* the child segment */}>       {/* app/[lang]/books/template.tsx */}
      <ErrorBoundary fallback={<BooksError />}>         {/* app/[lang]/books/error.tsx */}
        <Suspense fallback={<BooksLoading />}>          {/* app/[lang]/books/loading.tsx */}
          <BooksPage params={/* { lang: "en" } */} />   {/* app/[lang]/books/page.tsx */}
        </Suspense>
      </ErrorBoundary>
    </BooksTemplate>
  </BooksLayout>
</RootLayout>

Two things follow from the order. error.tsx sits inside BooksLayout and BooksTemplate, so it cannot catch an error thrown by them. An error in the books layout goes to the next error file above; the Bookshelf has none, so it reaches the root, where only a global-error file (or the built-in error page when there is none) can handle it. And loading.tsx sits inside the template, so the sidebar of the layout is never replaced by the loading fallback.

Trace 2: /en/books/42/reviews, a nested dynamic route

Table 6. Matching /en/books/42/reviews segment by segment
URL segment Folder that matches Files it contributes, outside in

en

app/[lang]/

layout.tsx (root layout).

books

app/[lang]/books/

layout.tsx, template.tsx, error.tsx, loading.tsx. Not page.tsx: books is not the last segment.

42

app/[lang]/books/[id]/ (dynamic, params.id = "42")

Nothing but the param. [id]/page.tsx is the leaf of /en/books/42, a sibling of reviews, not an ancestor.

reviews

app/[lang]/books/[id]/reviews/ (static)

loading.tsx and, as the last segment, page.tsx.

The tree of /en/books/42/reviews (conceptual)
<RootLayout params={/* { lang: "en" } */}>
  <BooksLayout params={/* { lang: "en" } */}>
    <BooksTemplate key={/* the [id] segment: "42" */}>
      <ErrorBoundary fallback={<BooksError />}>
        <Suspense fallback={<BooksLoading />}>           {/* books/loading.tsx */}
          {/* [id]: no files, nothing rendered, only params.id */}
          <Suspense fallback={<ReviewsLoading />}>       {/* books/[id]/reviews/loading.tsx */}
            <ReviewsPage params={/* { lang: "en", id: "42" } */} />
          </Suspense>
        </Suspense>
      </ErrorBoundary>
    </BooksTemplate>
  </BooksLayout>
</RootLayout>

The same tree as a diagram, with the boundary each file creates:

flowchart TB U["/en/books/42/reviews"] --> S1["segment en: app/[lang]"] S1 --> RL["RootLayout: app/[lang]/layout.tsx"] RL --> BL["BooksLayout: books/layout.tsx, kept across navigations"] BL --> BT["BooksTemplate: books/template.tsx, keyed by the id segment"] BT --> BE["Error boundary: books/error.tsx"] BE --> BS["Suspense: books/loading.tsx"] BS --> ID["segment 42: app/[lang]/books/[id], no files, adds params.id"] ID --> RS["Suspense: reviews/loading.tsx"] RS --> RP["ReviewsPage: reviews/page.tsx, the leaf"] BP["BookPage: [id]/page.tsx"] -. "leaf of /en/books/42, not rendered here" .- ID

Checking the trace in a browser

The DOM can be walked to print the data-file markers with their nesting. This was run in Chrome against next start (headless, through Playwright; pasting the function into DevTools gives the same result):

Print the nesting of the data-file markers (browser console)
[...document.querySelectorAll("[data-file]")]
  .filter((e) => !e.closest("[hidden]")) // skip streamed chunks not yet revealed
  .map((e) => {
    let depth = 0;
    for (let p = e.parentElement?.closest("[data-file]"); p; p = p.parentElement?.closest("[data-file]")) depth++;
    return "  ".repeat(depth) + e.dataset.file;
  })
  .join("\n");
Output for both URLs, at first paint and once the stream finished
# /en/books (first paint and settled are the same: the page is static)
app/[lang]/layout.tsx
  app/[lang]/books/layout.tsx
    app/[lang]/books/template.tsx
      app/[lang]/books/page.tsx

# /en/books/42/reviews, first paint
app/[lang]/layout.tsx
  app/[lang]/books/layout.tsx
    app/[lang]/books/template.tsx
      app/[lang]/books/[id]/reviews/loading.tsx

# /en/books/42/reviews, settled (about 800 ms later)
app/[lang]/layout.tsx
  app/[lang]/books/layout.tsx
    app/[lang]/books/template.tsx
      app/[lang]/books/[id]/reviews/page.tsx

Boundaries do not render elements of their own, so the error and Suspense levels are invisible in the DOM: what shows is which fallback is active. For the reviews page it is the reviews loading.tsx, not the books one, because a suspending component shows the fallback of its nearest Suspense boundary. The books loading.tsx is still useful: it covers /en/books/42 and every other route below books that has no closer loading file.

Two neighbouring URLs show how far a boundary file reaches:

Table 7. Neighbouring URLs of the trace (curl against next start)
URL Status What rendered and why

/en/books/99

200

notFound() in [id]/page.tsx. The not-found UI rendered and the HTML contained <meta name="robots" content="noindex"/>, but the status is 200, because books/loading.tsx wraps the page in a Suspense boundary and the response had started streaming before the page decided the book did not exist.

/en/books/broken

200

A page that throws on every request. The books error.tsx replaced it, inside the books layout and template.

The streaming page of this section explains how to keep a real 404 for unknown ids (decide before the first byte, for example in proxy.ts, or move the check above any loading boundary).

How a URL Is Matched: Priority and Conflicts

books/new, books/[id] and books/[...rest] all accept /en/books/new. Next.js resolves such overlaps with a fixed priority and rejects at build time the ones it cannot resolve. The dynamic routes page details each segment shape; this section collects the rules and runs them against the Bookshelf.

Priority: the most specific route wins

For one URL position, a static folder beats a dynamic [param] folder, which beats a catch-all [...param] (an optional catch-all [[...param]] is a catch-all that also matches its parent’s own URL). The comparison is made on whole routes: if the more specific branch fails further down the URL, a less specific route that matches the whole URL still wins over a 404.

flowchart TD A["URL position: next segment of the path"] --> B{"Static folder with this exact name?"} B -->|yes| OK["Try the rest of the URL inside it"] B -->|no| C{"Dynamic folder [param]?"} C -->|yes| OK C -->|no| D{"Catch-all [...param] or optional catch-all?"} D -->|yes| ALL["Take this and every remaining segment as an array"] D -->|no| NF["No route: not-found"] OK --> E{"Whole URL matched and the last folder has page or route?"} E -->|yes| WIN["This route renders"] E -->|"no: try the next less specific sibling"| C ALL --> WIN
Table 8. Which file answered each URL in the Bookshelf (next start, production build)
URL Winner Rule

/en/books/new

books/new/page.tsx

A static folder beats [id], so new is never an id.

/en/books/42

books/[id]/page.tsx

No static folder 42; the dynamic folder beats the catch-all.

/en/books/42/reviews

books/[id]/reviews/page.tsx

[id] takes 42, then the static reviews inside it.

/en/books/42/reviews/extra

books/[...rest]/page.tsx

The [id] branch has nothing for extra; the catch-all matches the whole remainder.

/api/books

app/api/books/route.ts

The static api folder beats [lang] at the first position, so the API is never treated as a language.

/en/Books

404

Folder names are matched case-sensitively; Books is not books.

/en/books/

308 to /en/books

The default trailingSlash: false redirects URLs with a trailing slash to the version without one.

React Router ranks routes the same way, independently of declaration order: with /books/*, /books/:id and /books/new declared in that order, matchRoutes from react-router 8.4 resolved /books/new to the static route, /books/42 to :id and /books/42/reviews to the splat.

Conflicts: the overlaps that fail the build

Some overlaps have no winner: two routes would match exactly the same URLs. next build refuses them. Each row below was produced by adding one file to the Bookshelf and running next build with Next.js 16.3.8:

Table 9. Route conflicts and the build errors they produce
Added file Error (abridged) Fix

books/[slug]/page.tsx next to books/[id]/

Ambiguous app routes detected: Ambiguous route pattern "/[]/books/[]" matches multiple routes: /[lang]/books/[id], /[lang]/books/[slug]

One param name per position. Two dynamic siblings are never distinguishable, whatever their names.

books/route.ts next to books/page.tsx

Conflicting route and page at /[lang]/books: route at /[lang]/books/route and page at /[lang]/books/page

Move the Route Handler to its own segment, for example app/api/books/route.ts.

(shop)/about/page.tsx next to about/page.tsx, both under [lang]

You cannot have two parallel pages that resolve to the same path. Please check /[lang]/(shop)/about and /[lang]/about.

Route groups are not in the URL; keep each URL in one group.

books/[[...opt]]/page.tsx next to books/page.tsx

You cannot define a route with the same specificity as an optional catch-all route ("/[lang]/books" and "/[*]/books/[[...opt]]").

Delete the page.tsx; the optional catch-all already handles /en/books with opt undefined.

x/[...a]/b/page.tsx

Catch-all must be the last part of the URL in route "/[lang]/x/[...a]/b".

A catch-all consumes the rest of the URL, so nothing can follow it.

pages/about.tsx next to an App Router /about

App Router and Pages Router both match path: /about

Shown in The Pages Router Equivalent: one router per URL.

Conflicts are caught by next build, so they never reach production. Priority rules never fail, they only decide, which makes a catch-all easy to add and hard to notice: a mistyped /en/books/42/review is answered by [...rest] with a 200. Give a catch-all page its own notFound() for values it does not understand.

Two things can change the URL: a hard navigation, in which the browser requests a new HTML document and throws the old one away, and a soft navigation (the docs call it a client-side transition), in which Next.js fetches the RSC payload of the next route and swaps it into the running page.

Table 10. Ways to go to another route
API Kind Use it for

<Link href="/books"> from next/link

Soft

The default for every in-app link. Renders an <a>, so it is accessible, crawlable and works with Ctrl/Cmd+click. Prefetches the route when it enters the viewport.

router.push(href), router.replace(href) from useRouter() in next/navigation

Soft

Navigation from event handlers and effects in Client Components (after a form, on a timer). replace does not add a history entry. Never use for plain links: it is not an <a>.

redirect(url) and permanentRedirect(url) from next/navigation

Soft on the client; a 307 (redirect) or 308 (permanentRedirect) response on the server

Redirecting from Server Components, Server Actions and Route Handlers.

window.history.pushState / replaceState

Soft, URL only

Changing the query string or path without any server work. They integrate with usePathname and useSearchParams.

A plain <a href="/books">, window.location.assign(), typing a URL, reload, a link to another origin

Hard

External links, downloads, and routes that must reload the document.

Two small hooks answer "where am I?": usePathname() returns the pathname (/books/2/reviews) and useParams() returns the dynamic params; both are in next/navigation and work only in Client Components. They are not interchangeable with the Pages Router’s next/router, as the comparison later shows.

What happens on a click

The sequence below follows a click on a Link to /books/2 while the user is on /books (soft), and a click on a plain <a> to the same URL (hard). It was checked in a real browser; see the evidence after the diagram.

sequenceDiagram autonumber actor U as User participant B as Browser, running page participant R as Next.js client router participant S as Next.js server Note over B,S: Soft navigation (Link, router.push) U->>R: click on Link to /books/2 R->>R: prefetched? use the cached RSC payload R->>S: GET /books/2?_rsc=hash (only if not cached) S-->>R: RSC payload of the changed segments R->>B: React transition: swap the page, keep the layouts R->>B: history.pushState, scroll to the new page Note over B,S: Hard navigation (plain a, reload, typed URL) U->>B: click on a plain a href B->>S: GET /books/2 (new HTML document) S-->>B: HTML plus RSC payload plus script tags B->>B: discard the old page, new document, hydrate everything

The second diagram shows the decision the router takes for every link, including the cases that turn a soft navigation into a hard one.

flowchart TD C["Navigation requested"] --> Q1{"Link, router.push or router.replace?"} Q1 -->|"no: a, location, typed URL, reload"| H["Hard navigation: new HTML document"] Q1 -->|yes| Q2{"Same root layout?"} Q2 -->|"no: different root layouts"| H Q2 -->|yes| Q3{"RSC payload prefetched and fresh?"} Q3 -->|yes| T["Instant client-side transition"] Q3 -->|no| F["Fetch RSC payload, show loading UI if any"] F --> Q4{"Response is an RSC payload of the same deployment?"} Q4 -->|"no: JSON, a file, HTML, another deployment"| H Q4 -->|yes| T T --> K["Shared layouts kept: state, focus, scroll position"] T --> N["Only the changed segment re-renders"]

Evidence from the Bookshelf, in Chromium against next start: clicking Link`s from `/books/1 to /books/2 and on to /books/2/reviews changed the URL and the <h1>, moved the active link in BookNav (useSelectedLayoutSegment() returned 2), requested /books/2?_rsc=<hash> instead of a document, and left the books layout’s MountedAt timestamp unchanged. Navigating to /about, outside the books layout, removed it, and coming back mounted it again with a new time. The next subsection measures the same things request by request.

Soft and hard navigation, request by request

The experiments use the trace Bookshelf, whose root layout gets three extra links for the purpose: a Link to /fr/books, a plain <a href="/en/books/7">, and a Link to the /api/books Route Handler.

What is requested. A hard navigation asks for the URL as a document. A soft navigation asks for the same URL with an RSC: 1 request header (and a _rsc cache-busting query parameter), and receives a React Server Components payload instead of HTML. Both are visible with curl:

The same URL as a document and as an RSC payload
$ curl -s -D - -o /dev/null http://localhost:3711/en/books/42/reviews \
    | grep -i -E "^HTTP|^content-type|^vary"
HTTP/1.1 200 OK
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
Content-Type: text/html; charset=utf-8

$ curl -sL -D - -o /dev/null -H "RSC: 1" http://localhost:3711/en/books/42/reviews \
    | grep -i -E "^HTTP|^content-type|^location"
HTTP/1.1 307 Temporary Redirect
location: /en/books/42/reviews?_rsc
HTTP/1.1 200 OK
Content-Type: text/x-component

The HTML document was 10,991 bytes and the full RSC payload 6,387 bytes. The Vary header lists the request headers the response depends on; the CDN guide explains each one, and why a CDN must forward rsc (if it strips it, the server answers with HTML, the client router cannot use it, and every click becomes a full page load).

Which layouts are reused. The full payload is not what a click downloads. Clicking the Link from /en/books/42 to /en/books/42/reviews in Chrome sent three router headers, captured by wrapping window.fetch in the page:

Table 11. Request headers of a soft navigation (Next.js 16.3.8)
Header Value and meaning

rsc

1: return an RSC payload, not HTML.

next-router-state-tree

The URL-encoded tree of segments the client already has (lang, books, [id] = 42, reviews), with the page leaf marked refetch. The server renders only what is marked.

next-url

/en/books/42: the page the navigation starts from. The docs use it for intercepting routes.

The response was 1,189 bytes and contained neither the books layout, nor the template, nor the root navigation: only the reviews page. Replaying the request with curl shows the effect of the state tree directly:

A targeted payload versus the full payload (the tree format is internal, shown only to explain the mechanism)
$ TREE=$(node -e 'const leaf = ["__PAGE__", {}, null, "refetch", 4096];
const tree = ["", { children: [["lang","en","d",null], { children: ["books", { children: [["id","42","d",null],
  { children: ["reviews", { children: leaf }, null, null, 4100] }, null, null, 4104] }, null, null, 4108] },
  null, null, 4120] }, null, null, 4120];
console.log(encodeURIComponent(JSON.stringify(tree)))')

$ curl -sL -H "RSC: 1" -H "Next-Router-State-Tree: $TREE" -o /dev/null -w "%{size_download}\n" \
    http://localhost:3711/en/books/42/reviews
1198
$ curl -sL -H "RSC: 1" -H "Next-Router-State-Tree: $TREE" http://localhost:3711/en/books/42/reviews \
    | grep -o 'data-file":"[^"]*' | sort -u
data-file":"app/[lang]/books/[id]/reviews/page.tsx

The same request without the state tree returns the full payload, with the markers of both layouts, the template and both loading files; the CDN guide documents this fallback. That is partial rendering on the wire: the shared layouts are neither re-rendered on the server nor sent again.

What happens to client state and scroll. The MountedAt probes in the books layout and in the books template each show a mount time and an <input>. Text was typed into both before each navigation, and a marker property was set on window to detect a new document:

Table 12. What survived each navigation (Chrome, next start)
Navigation Document Books layout Books template

Link, /en/books/7 to /en/books/42

same

kept: same mount time, typed text kept

remounted: new mount time, input empty

Link, /en/books/42 to /en/books/42/reviews

same

kept

kept: same mount time

Link, /en/books/42/reviews to /fr/books

same

remounted: new mount time

remounted

Link, /en/books to /en/books/broken (a page that throws)

same

kept, typed text kept

kept; error.tsx rendered inside it

plain <a>, /fr/books to /fr/books/7

new

remounted, typed text lost

remounted

Link, /fr/books/7 to /api/books (a Route Handler)

new

gone: the browser showed the JSON

gone

Each row has a reason:

  • The template is keyed by its child segment. It remounted when [id] changed from 7 to 42, not when only the deeper reviews segment changed: templates "remount when that segment (including its dynamic params) changes".

  • A changed param remounts the subtree below it. /en to /fr kept the document, because the root layout file is the same (this is not a navigation between root layouts), and the root layout updated <html lang> to fr; but every layout below [lang] is a new instance, so a language switch loses the sidebar state.

  • A Server Component error does not reload the page. The error crossed the wire in the payload and the nearest error.tsx rendered it in place. In production the message is replaced by a minified React error, and the server log carries the real one with a matching digest, so do not build UI on error.message.

  • A Link to something that is not a page ends in a document load. The router prefetched /api/books with the RSC headers, got application/json, and on the click fell back to a browser navigation, wasting the prefetch. Use a plain <a> for Route Handlers, downloads and files.

Scroll was measured on /en/books with a 2,000-pixel spacer above a Link. After scrolling to the link (scrollY 1,919.5) and clicking it, /en/books/42 started at scrollY 0: the new page was not in the viewport, so Next.js scrolled to the top of its first element. Pressing Back restored scrollY 1,919.5, as the browser does for history navigations. scroll={false} on the Link turns this scroll management off; see The History API and Scroll Restoration.

What triggers a hard navigation. Collecting the observed cases and the documented ones:

Table 13. Hard navigations and where they come from
Trigger Why it is a document load Evidence

A plain <a>, window.location, a typed URL, a reload, the first visit

The browser itself requests a document; the Next.js router is not involved.

Observed above.

Crossing different root layouts

The <html> and <body> themselves differ, so they cannot be kept. Changing a param of the same root layout (/en to /fr) is not this case.

Documented; see Route Groups.

A Link to a URL that does not answer with an RSC payload (a Route Handler, a file, a URL rewritten to another server)

The router expected text/x-component and received something else.

Observed with /api/books.

A link to another zone (a separate Next.js app under the same domain)

Each zone is its own application; the docs say to link to it with <a>, not Link.

Documented (multi-zones).

A new deployment while the tab is open, with deploymentId set

The client detects that the server’s deployment ID differs from its own and does a full reload, so that it never mixes assets of two versions.

Documented (deploymentId).

A CDN that strips the rsc request header

The server answers with HTML, which the router cannot use.

Documented (CDN guide).

Without any probe, the Network panel of the browser developer tools tells the two apart: a soft navigation adds a fetch request with rsc: 1 and a text/x-component response, a hard navigation a document request.

Partial Rendering of Shared Layouts

A soft navigation does not re-render the page: it re-renders what differs. When you go from /books/2 to /books/2/reviews, the root layout and the books layout are the same, so React keeps them: their client state, their DOM, their focus, and any scroll position inside them survive, and their server-rendered output is not requested again. Only the [id] page is replaced by the reviews page. The docs state it in one sentence: on navigation, layouts preserve state, remain interactive, and do not rerender.

Three mechanisms cooperate:

  1. Prefetching. A Link that enters the viewport prefetches its route in the background. For a static route the whole route is prefetched; for a dynamic route, down to the nearest loading file, so the shared layouts and the loading fallback are ready before the click. prefetch={false} turns it off for long lists of links. Prefetching is enabled in production only (not in next dev), and it only starts after Link has hydrated.

  2. Layout deduplication and incremental prefetching (Next.js 16). When many prefetched URLs share a layout, the layout payload is downloaded once, and only the parts not already in the client cache are requested. You may see more, smaller prefetch requests than in Next.js 15; the total transfer is lower.

  3. Streaming and loading. If the target page is dynamic, the server streams the changed part. A loading file (or a <Suspense> boundary) lets the router show a fallback immediately and keep the shared layouts interactive and the navigation interruptible.

The corollary is a design rule: put state and expensive UI in the layout, and the things that change in the page. The reverse also holds, and is the most common surprise: a layout does not re-run when the URL changes within it. Code that must run on every navigation (a page-view counter, a form that resets, a useEffect with a per-page effect) does not belong in a layout; it belongs in the page or in a template, which remounts on every navigation and has its own page in this section.

The Pages Router Equivalent

The Pages Router is still supported and is what the two books in this section’s bibliography teach. Its idea is the same (file-system routing, Link, client-side transitions) but the mapping is file to route, not folder to segment, and there is no built-in nesting of layouts.

Table 14. The Bookshelf in the pages/ directory
File URL

pages/index.tsx

/

pages/legacy/books/index.tsx

/legacy/books

pages/legacy/books/[id].tsx

/legacy/books/2

pages/_app.tsx

Not a route: the wrapper around every page. A "layout for everyone" is written here.

Layouts are a pattern, not a convention: you write a layout component, and a page can export a getLayout function. _app applies it. Because the same React component sits at the same place before and after the transition, React keeps its state, which is the Pages Router’s version of "layouts persist".

pages/_app.tsx: the layout pattern from the official docs, typed
import type { AppProps } from "next/app";
import type { NextPage } from "next";
import type { ReactElement, ReactNode } from "react";

type PageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode;
};

export default function MyApp({
  Component,
  pageProps,
}: AppProps & { Component: PageWithLayout }) {
  const getLayout = Component.getLayout ?? ((page: ReactElement) => page);
  return getLayout(<Component {...pageProps} />);
}
pages/legacy/books/[id].tsx: a dynamic page with next/router
import Link from "next/link";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
import { LegacyShelfLayout } from "@/components/legacy-shelf-layout";

export default function LegacyBookPage() {
  const router = useRouter();
  const { id } = router.query; // string | string[] | undefined
  return (
    <main>
      <h1>Legacy book {id}</h1>
      <Link href="/legacy/books/1">Book 1</Link>{" "}
      <Link href="/legacy/books/2">Book 2</Link>
    </main>
  );
}

LegacyBookPage.getLayout = function getLayout(page: ReactElement) {
  return <LegacyShelfLayout>{page}</LegacyShelfLayout>;
};

Run in the same scratch app, next build listed the page under Route (pages) as static (○ /legacy/books/[id]), and clicking from Book 1 to Book 2 kept LegacyShelfLayout mounted (its mount timestamp did not change), as in the App Router. Two differences show in the raw HTML. First, the HTML of /legacy/books/2 contained <h1>Legacy book </h1> with no id and "query":{} in the embedded NEXT_DATA JSON: a page without getServerSideProps is statically optimised, so router.query is empty on the server and during hydration, and the real value appears only after the router is ready on the client. That is the Pages Router’s well-known isReady gotcha; the App Router has no equivalent, because params is passed to the page on the server. Second, the page is a Client Component by nature (it uses a Hook), while an App Router page is a Server Component by default.

Both routers can coexist in one project, which is how an incremental migration works: app/ and pages/ side by side, Link navigating between them (a hard navigation between the two routers). They must not define the same URL. Adding pages/about.tsx next to the Bookshelf’s app/(marketing)/about/page.tsx fails next build with:

Error: App Router and Pages Router both match path: /about
Next.js does not support having both App Router and Pages Router routes matching the same path. Please remove one of the conflicting routes.

One Bookshelf, Three Mental Models

The three routers describe the same thing, a URL mapped to nested components, from three different starting points. Applying each to the same three Bookshelf URLs (/en/books, /en/books/42 and /en/books/42/reviews) makes the differences concrete.

React Router: nesting is data. The route table is a tree of objects. A parent route renders an <Outlet /> where the matching child goes; an index route is the child for the parent’s own URL. The components are ordinary: two layouts that render <Outlet /> and three leaves that read useParams().

React Router 8.4: the three URLs as a route object (createBrowserRouter(routes) in the browser)
export const routes: RouteObject[] = [
  {
    path: "/:lang",
    element: <RootLayout />,
    children: [
      {
        path: "books",
        element: <BooksLayout />,
        children: [
          { index: true, element: <BooksIndex /> },
          { path: ":id", element: <Book /> },
          { path: ":id/reviews", element: <Reviews /> },
        ],
      },
    ],
  },
];

Type-checked with tsc --strict and rendered in Node.js with createMemoryRouter(routes, { initialEntries: [url] }) and renderToString, the three URLs produced these match chains, and markup with the leaf nested in <section data-route="books-layout"> inside the root <div> in each case:

React Router: the match chain of each URL (matchRoutes)
/en/books            ["/:lang", "books", "(index)"]
/en/books/42         ["/:lang", "books", ":id"]
/en/books/42/reviews ["/:lang", "books", ":id/reviews"]

App Router: nesting is folders. The same tree is the folder tree of Tracing Two URLs Through the Full Bookshelf: app/[lang]/layout.tsx is RootLayout, app/[lang]/books/layout.tsx is BooksLayout, books/page.tsx is the index route, books/[id]/page.tsx is :id, and books/[id]/reviews/page.tsx is :id/reviews. The Outlet is the children prop. Three things have no React Router counterpart in the folder version: the files run as Server Components, the special files (template, error, loading) attach boundaries by position instead of by route properties, and params arrive as a prop instead of through a hook.

Pages Router: one file per URL, layouts by hand. Each URL is a file, and only _app is shared automatically:

The three URLs in pages/ (under a legacy prefix, so that they do not collide with the App Router routes)
pages/
├── _app.tsx                          # wraps every page; a hand-written layout goes here
└── legacy/[lang]/books/
    ├── index.tsx                     # /legacy/en/books
    ├── [id].tsx                      # /legacy/en/books/42
    └── [id]/reviews.tsx              # /legacy/en/books/42/reviews

next build listed the three as static routes under Route (pages). [id].tsx and the folder [id]/ coexist, exactly as :id and :id/reviews are siblings in React Router and [id]/page.tsx and [id]/reviews/page.tsx are siblings in the App Router. All three routers agree that the book page is not the parent of the reviews page. Each of the three pages printed router not ready in the server HTML, the statically optimised router.query issue described in The Pages Router Equivalent.

The comparison that matters is what each router keeps when the user goes from /en/books/42 to /en/books/42/reviews:

Table 15. The same navigation in three routers
React Router (data mode) Pages Router App Router

Who decides the match

The browser, from the route objects.

The client router for a Link click; the server for the first request.

The server renders the target segments; the client router knows the segment tree.

What is downloaded

The lazy chunk of the new route, if any, and what its loader fetches.

The page’s JavaScript chunk and, if the page has getStaticProps or getServerSideProps, its props.

An RSC payload of only the changed segments (1,189 bytes in Soft and hard navigation, request by request), plus any new Client Component chunks.

What stays mounted

RootLayout and BooksLayout: they are the same elements in the new match chain.

_app, and a per-page layout only if both pages return the same layout component from getLayout.

Both layouts and the template (keyed by [id], which did not change).

App Router, Pages Router and React Router Side by Side

Table 16. Routing concepts in three routers
Concept App Router (app/) Pages Router (pages/) React Router 7 or later (library mode)

Route table

Folders; a page file makes a segment reachable.

Files; every file in pages/ is a route.

createBrowserRouter([...]) or <Routes>; hand-written.

Index route

app/books/page.tsx

pages/books/index.tsx

{ index: true, element }

Dynamic segment

[id] folder; params (a Promise) is a prop of page and layout.

[id].tsx; router.query.id from useRouter() (next/router).

path: ':id'; useParams().

Catch-all

[...slug] and [[...slug]]

[...slug].tsx and [[...slug]].tsx

path: '*' (splat)

Layout

layout.tsx per folder, nested automatically; kept alive across navigation.

A component you write and apply in _app or via a getLayout page property.

A parent route with element and <Outlet />.

Navigate with a component

<Link> from next/link

<Link> from next/link

<Link> / <NavLink>

Navigate in code

useRouter() from next/navigation: push, replace, refresh, prefetch, back

useRouter() from next/router: push, replace, reload, back, events

useNavigate()

Read the URL in a component

usePathname(), useSearchParams(), useParams() (Client Components); params and searchParams props in pages

router.pathname, router.query, router.asPath

useLocation(), useParams(), useSearchParams()

Who matches the URL

The server (every request), then the client router for soft navigations.

The server for the first request, then the client router.

Only the browser, after the bundle loaded; the host must serve index.html for every path.

Initial render

Server Components; HTML on the first visit; code splitting per segment.

HTML from the page component; data from getServerSideProps or getStaticProps.

Client-rendered shell, unless you add server rendering yourself or use the framework mode.

Rendered by default on

Server (Server Components)

Server for the first load, then client

Client

Under Cache Components

Routing does not change with cacheComponents: true, but two reads that the previous model tolerates are rejected at build time if they are not inside <Suspense>: runtime data (params of a segment that has no generateStaticParams, searchParams, cookies(), headers()) and routing hooks such as usePathname() and useSelectedLayoutSegment(). The first build of this page’s example without the final changes failed under Cache Components with blocking-prerender-client-hook (a hook read outside <Suspense>) and blocking-prerender-dynamic (params awaited outside <Suspense>). Both are fixed in the code above: BookNav is wrapped in a Suspense boundary in the layout, and both pages declare generateStaticParams, so the params are known at build time. With those two changes the same files build in both models; with CACHE_COMPONENTS=1 the build succeeded and the route table marked the /books/[id] and /books/[id]/reviews entries ◐ Partial Prerender, next to the prerendered ○ paths for ids 1 to 3. The caching and Partial Prerendering pages of this section explain the model; the two errors are documented at blocking-prerender-client-hook and blocking-prerender-dynamic.

The probe MountedAt itself needs care: a first version computed new Date() in the useState initialiser and was rejected by Cache Components (blocking-prerender-current-time-client), because the value would be frozen at build time. Reading the clock in an effect, as above, is the fix. The Instant Navigations of Next.js 16.3 (cacheComponents plus partialPrefetching) go one step further: the per-route app shell is prefetched so that a click shows the static and cached part of the next route at once. They are opt-in in 16.3.8 and are covered in the navigation page of this section.

Pitfalls

Table 17. Common routing mistakes and their fixes
Symptom Cause Fix

A route returns 404 although the folder exists

The folder has no page (or route) file, or the file is named Page.tsx or index.tsx. Reserved names are lower-case: page, layout, route.

Add page.tsx. In the App Router index.tsx has no special meaning.

A new file next to a page suddenly shows up as a URL

It was named page, or route, or a metadata convention such as icon, by accident.

Rename it. Other names are safe to colocate; use a _private folder to be explicit.

Error: App Router and Pages Router both match path

The same URL is defined in app/ and pages/, or two route groups resolve to the same path.

Remove one definition; migrate a route at a time.

params.id is undefined, or Property 'id' does not exist on type 'Promise'

Since Next.js 15 params is a Promise; Next.js 16 removed synchronous access.

const { id } = await params; in an async function. In a Client Component, use(params).

The layout does not update when I navigate

By design: a layout is kept alive within its segment. It receives neither the URL nor new searchParams.

Read the URL in a Client Component (usePathname, useSelectedLayoutSegment), or move the code to the page or to a template.

The whole page reloads, the layout state is lost and links are not prefetched

A plain <a> was used, or navigation crossed two root layouts, or window.location was assigned.

Use Link or router.push; keep one root layout unless the groups really are different documents.

useRouter imported from next/router in app/: NextRouter was not mounted

The Pages Router’s router is not available in the App Router.

Import from next/navigation; use usePathname, useSearchParams and useParams instead of router.query. In pages/, next/compat/router is the variant that does not throw.

router.query is empty on first render (Pages Router)

Automatic static optimisation: the router is not ready until the client has hydrated.

Wait for router.isReady in an effect, or read the value in getServerSideProps or getStaticProps.

Active-link highlighting is wrong or the build says "outside of Suspense"

usePathname(), useSelectedLayoutSegment() or params read in a layout that is prerendered under Cache Components.

Wrap the Client Component in <Suspense> and give dynamic segments generateStaticParams.

Search crawlers or users see /books/99 as 200 with an empty page

notFound() was thrown inside a streamed <Suspense> boundary after the response status was already sent. A loading file in any segment above the page is such a boundary (see Checking the trace in a browser).

Decide existence outside any <Suspense> boundary (as BookPage does), or accept a 200 and add noindex. The streaming page of this section covers the details.

Many tiny prefetch requests after upgrading to 16

Layout deduplication and incremental prefetching split prefetches into smaller requests.

Expected; the total bytes are lower. Use prefetch={false} on very long lists of links.

<Link> inside <Link>, or a button wrapped in a link

Invalid HTML: it renders nested <a> or an <a> containing interactive content, which breaks keyboard and screen-reader behaviour.

Style the Link as a button, or use router.push in a `button’s handler.

Ambiguous app routes detected at build time

Two dynamic folders at the same position, such as [id] and [slug] under books.

Keep one dynamic folder per position; put the other route under a static segment (books/by-slug/[slug]).

Conflicting route and page, Catch-all must be the last part of the URL, or two groups with one path

Overlapping routes with no winner.

See the conflicts table in Conflicts: the overlaps that fail the build.

A mistyped URL returns 200 instead of 404

A catch-all ([...rest]) or a permissive dynamic segment accepts it.

Validate the params in the page and call notFound(); for a known, finite set, dynamicParams = false with generateStaticParams.

/de/books renders a page in a language the site does not have

A dynamic root segment such as [lang] accepts any value.

export const dynamicParams = false next to generateStaticParams in app/[lang]/layout.tsx turns unknown values into 404s.

A Link to /api/... or to a file causes a full page load, and the Network panel shows a wasted prefetch

The router prefetches the URL as an RSC payload, gets JSON or a file, and falls back to a browser navigation.

Use a plain <a> for Route Handlers, downloads and other non-page URLs.

The template’s state resets on some navigations but not others, or a language switch resets every sidebar

A template is keyed by its child segment; a changed layout param ([lang]) makes every layout below it new.

See Soft and hard navigation, request by request. Keep state that must survive above the segment that changes, in the URL or in storage.

Client navigations through a CDN always reload the page

The CDN strips the rsc request header or caches one response for both variants, so the router receives HTML.

Forward rsc and the router headers, and include _rsc in the cache key, as the CDN guide describes.

What Changed Since Next.js 13 and 14

The App Router reached stable in Next.js 13.4; most tutorials and both books in the bibliography were written before 13.4, or with the Pages Router. This is the routing-relevant history, with the version where each change landed. Dates come from the version-history tables of the official pages and the release posts.

Table 18. Routing-relevant changes
Version Change

13.0

app/ introduced, with page and layout. <Link> no longer needs a child <a>. The useRouter hook in next/navigation is introduced next to the Pages Router’s next/router.

15.0 (2024-10-21)

params and searchParams of pages (and params of layouts) become Promises. A codemod exists. Client router cache: staleTimes.dynamic defaults to 0 (pages are not reused from the cache for 30 seconds any more).

15.3

onNavigate on Link.

15.4

prefetch="auto" is an alias of the default Link prefetch behaviour; router.prefetch accepts onInvalidate.

16.0 (2025-10-21)

Synchronous access to params and searchParams is removed. Prefetch cache rewritten: layout deduplication and incremental prefetching. Parallel route slots require an explicit default file. Turbopack is the default bundler. middleware.ts is renamed to proxy.ts.

16.2 (2026-03-18)

Link accepts transitionTypes for the View Transition API (the router.push and router.replace options take it too in the 16.3.8 docs).

16.3 (2026-08-03)

Instant Navigations (opt-in, needs cacheComponents and partialPrefetching): the per-route app shell is prefetched so a click shows static and cached UI immediately. next/root-params gives Server Components access to the root-level params (such as a locale). Both are covered by later pages of this section.

Where the Books Are Outdated

The two books that this section consulted (published in 2022 and 2023) teach routing with the Pages Router, pages/, getStaticProps and getServerSideProps; the second one also touches the first App Router files of 13.x, such as head.js, which no longer exist. What to take, and what to replace:

Table 19. Outdated routing advice and the current approach
In the books Current approach

Routing means files under pages/, with getStaticProps/getServerSideProps next to each page.

The App Router is the recommended router: folders and page files, async Server Components, generateStaticParams. The Pages Router is supported, and both can coexist while you migrate.

Dynamic routes read router.query.slug with useRouter from next/router.

In app/, params is a prop of page and layout (a Promise since 15, awaited), and the hooks are in next/navigation. router.query still applies in pages/.

<Link href="..."><a>text</a></Link> and passHref on a child.

Link renders its own <a>; the child <a> stopped being required in 13. The legacyBehavior prop of older versions is not part of the current Link documentation.

A persistent layout needs getLayout in _app.

A layout file per folder gives nested layouts that persist without extra code.

A custom Express or Fastify server for routing rules.

Next.js routes by file system; redirects, rewrites and headers go in next.config.ts and request-time logic goes in proxy.ts. Custom servers are rarely justified.

Locale routing with the i18n key of next.config.js (Pages Router only).

app/[locale] segments with locale detection in proxy.ts; the i18n page of this section shows it.

Summary

  • Routing is the function from URL to component tree. A SPA computes it in the browser from a route table you write; Next.js computes it on the server from the folder structure, and the client router avoids document reloads.

  • Folders are segments, page makes them public, layout wraps the segment and everything below, and anything else is colocated and private. [id], [...slug], [[...slug]] and (group) shape params and URLs.

  • The tree for a URL is root layout, nested layouts, then one page; the page is a leaf, not an ancestor of its child routes.

  • Soft navigation (Link, router.push) fetches an RSC payload and keeps shared layouts alive; hard navigation (<a>, reload, crossing root layouts) is a new document.

  • Inside a segment the files nest as layout, template, error, loading, not-found, then the page or the next segment; a folder without files (such as [id] on the way to reviews) adds only a param.

  • Matching prefers static over dynamic over catch-all over optional catch-all, on whole routes; overlaps that have no winner (two dynamic siblings, route next to page, two groups with one URL) fail the build.

  • A soft navigation sends RSC: 1 and the client’s segment tree and receives only the changed segments; layouts keep their state, a template remounts when its child segment changes, and a changed layout param remounts everything below it.

  • The Pages Router does the same by files and by _app; coexistence is allowed but URLs must not overlap. React Router, the Pages Router and the App Router agree on the shape of the tree and on match priority, and differ on where it runs.