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:
-
The browser’s History API to change the address bar without asking the server for a new document.
-
A route table that matches the current URL (
location.pathname) against patterns such as/books/:id. -
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:
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.
Source: MDN: History API
React Router packages the three pieces. This is the same Bookshelf shape as a declarative route object with a nested layout:
children and Outletimport { 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:
| 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 |
In the file system: folders are URL segments, |
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 |
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 ( |
Automatic per route segment, and Server Components add no client JavaScript at all. |
Layouts |
Hand-written nesting with |
The |
Source: Layouts and Pages and Linking and Navigating
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 segmentsbooksand[id]. -
Files with reserved names define the UI shown for a segment.
pageis the one that makes a segment reachable,layoutwraps the segment and its descendants, androute(a Route Handler, covered on its own page) answers with data instead of UI.
| File | Extensions | Purpose |
|---|---|---|
|
|
The UI of a route. Default-export a React component. A segment is publicly accessible only if it contains a |
|
|
UI shared by a segment and all segments below it. Receives |
|
|
Other special files. Introduced in later pages of this section. |
Source: Project structure: routing files
A route is made of segments, and the segment kinds are:
| Folder | Matches | params |
|---|---|---|
|
|
none |
|
|
|
|
|
|
|
the same, and |
|
|
|
none |
|
nothing (a leading underscore makes a private folder: opted out of routing) |
none |
Source: Dynamic Routes, Route Groups and Project structure: colocation
Three consequences are easy to miss:
-
Only
pageandroutemake 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 whatpagereturns is sent to the client. A build withapp/books/format.tsandapp/books/_components/helper.tsadded 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)/aboutis/about. Two groups that resolve to the same URL ((marketing)/aboutand(shop)/about) are a build error. -
Dynamic segments return strings (or arrays of strings).
params.idis'2', never2, and unknown params are not an error by themselves: whether/books/99is a 404 is decided by your code (notFound()) or bydynamicParamsfor 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.
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):
next buildRoute (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.
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.
Source: Layouts and Pages: creating a layout and layout.js
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.
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>
);
}
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>
);
}
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.
"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>
);
}
"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>
);
}
Source: useSelectedLayoutSegment and page.js
Built and started with next start, the routes answered as follows:
| Request | Status | Why |
|---|---|---|
|
200 |
Static pages. |
|
200 |
Prerendered through |
|
404 |
|
|
404 |
No segment |
|
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.
The mapping has a few rules that explain every behaviour in this page:
-
Segments are matched left to right, one per folder.
/books/2/reviewsmatchesapp,books,[id]andreviews. -
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/reviewsisRootLayout, containingBooksLayout, containingReviewsPage. There is no layout in[id]orreviews, so those segments add only a param and a page. -
A page is a leaf.
books/[id]/page.tsxis 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. -
Params flow down.
paramsis{ 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. -
Layouts do not receive the URL. A layout is shared by many URLs, so it has no
searchParamsand no pathname. To react to the URL from a layout, use a Client Component withusePathname(),useSelectedLayoutSegment()oruseSelectedLayoutSegments(), asBookNavdoes.
The same components, written as the JSX that Next.js assembles for you:
/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>
Source: Layouts and Pages: nesting layouts and layout.js
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
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:
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>
);
}
Source: layout.js: root layout and dynamicParams
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
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>
);
}
Source: template.js, error.js and loading.js
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:
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
| URL segment | Folder that matches | Files it contributes, outside in |
|---|---|---|
(root) |
|
Nothing: there is no |
|
|
|
|
|
|
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):
/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>
Source: Project structure: component hierarchy and template.js
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
| URL segment | Folder that matches | Files it contributes, outside in |
|---|---|---|
|
|
|
|
|
|
|
|
Nothing but the param. |
|
|
|
/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:
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):
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");
Source: MDN: Element.closest
# /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:
| URL | Status | What rendered and why |
|---|---|---|
|
200 |
|
|
200 |
A page that throws on every request. The books |
Source: loading.js: status codes and notFound
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.
| URL | Winner | Rule |
|---|---|---|
|
|
A static folder beats |
|
|
No static folder |
|
|
|
|
|
The |
|
|
The static |
|
404 |
Folder names are matched case-sensitively; |
|
308 to |
The default |
Source: Dynamic Route Segments and trailingSlash
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:
| Added file | Error (abridged) | Fix |
|---|---|---|
|
|
One param name per position. Two dynamic siblings are never distinguishable, whatever their names. |
|
|
Move the Route Handler to its own segment, for example |
|
|
Route groups are not in the URL; keep each URL in one group. |
|
|
Delete the |
|
|
A catch-all consumes the rest of the URL, so nothing can follow it. |
|
|
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.
Navigation: Soft versus Hard
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.
The APIs that navigate
| API | Kind | Use it for |
|---|---|---|
|
Soft |
The default for every in-app link. Renders an |
|
Soft |
Navigation from event handlers and effects in Client Components (after a form, on a timer). |
|
Soft on the client; a 307 ( |
Redirecting from Server Components, Server Actions and Route Handlers. |
|
Soft, URL only |
Changing the query string or path without any server work. They integrate with |
A plain |
Hard |
External links, downloads, and routes that must reload the document. |
Source: Link, useRouter and Linking and Navigating: native History API
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.
The second diagram shows the decision the router takes for every link, including the cases that turn a soft navigation into a hard one.
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:
$ 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:
| Header | Value and meaning |
|---|---|
|
|
|
The URL-encoded tree of segments the client already has ( |
|
|
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:
$ 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:
| Navigation | Document | Books layout | Books template |
|---|---|---|---|
|
same |
kept: same mount time, typed text kept |
remounted: new mount time, input empty |
|
same |
kept |
kept: same mount time |
|
same |
remounted: new mount time |
remounted |
|
same |
kept, typed text kept |
kept; |
plain |
new |
remounted, typed text lost |
remounted |
|
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 from7to42, not when only the deeperreviewssegment changed: templates "remount when that segment (including its dynamic params) changes". -
A changed param remounts the subtree below it.
/ento/frkept 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>tofr; 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.tsxrendered it in place. In production the message is replaced by a minified React error, and the server log carries the real one with a matchingdigest, so do not build UI onerror.message. -
A
Linkto something that is not a page ends in a document load. The router prefetched/api/bookswith the RSC headers, gotapplication/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.
Source: template.js: behavior, error.js and Link: scroll
What triggers a hard navigation. Collecting the observed cases and the documented ones:
| Trigger | Why it is a document load | Evidence |
|---|---|---|
A plain |
The browser itself requests a document; the Next.js router is not involved. |
Observed above. |
Crossing different root layouts |
The |
Documented; see Route Groups. |
A |
The router expected |
Observed with |
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 |
Documented (multi-zones). |
A new deployment while the tab is open, with |
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 ( |
A CDN that strips the |
The server answers with HTML, which the router cannot use. |
Documented (CDN guide). |
Source: layout.js: root layout, Multi-zones: linking between zones, deploymentId and CDN caching: what you must preserve
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:
-
Prefetching. A
Linkthat 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 nearestloadingfile, 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 innext dev), and it only starts afterLinkhas hydrated. -
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.
-
Streaming and
loading. If the target page is dynamic, the server streams the changed part. Aloadingfile (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.
| File | URL |
|---|---|
|
|
|
|
|
|
|
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".
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} />);
}
next/routerimport 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.
Source: Pages Router: useRouter and Per-page layouts
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.
Source: Migrating to the App Router
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().
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 /> },
],
},
],
},
];
Source: React Router: Route Object
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:
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:
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:
| React Router (data mode) | Pages Router | App Router | |
|---|---|---|---|
Who decides the match |
The browser, from the route objects. |
The client router for a |
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 |
The page’s JavaScript chunk and, if the page has |
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 |
|
|
Both layouts and the template (keyed by |
App Router, Pages Router and React Router Side by Side
| Concept | App Router (app/) |
Pages Router (pages/) |
React Router 7 or later (library mode) |
|---|---|---|---|
Route table |
Folders; a |
Files; every file in |
|
Index route |
|
|
|
Dynamic segment |
|
|
|
Catch-all |
|
|
|
Layout |
|
A component you write and apply in |
A parent route with |
Navigate with a component |
|
|
|
Navigate in code |
|
|
|
Read the URL in a component |
|
|
|
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 |
Initial render |
Server Components; HTML on the first visit; code splitting per segment. |
HTML from the page component; data from |
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.
Source: Instant navigation
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
A route returns 404 although the folder exists |
The folder has no |
Add |
A new file next to a |
It was named |
Rename it. Other names are safe to colocate; use a |
|
The same URL is defined in |
Remove one definition; migrate a route at a time. |
|
Since Next.js 15 |
|
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 |
Read the URL in a Client Component ( |
The whole page reloads, the layout state is lost and links are not prefetched |
A plain |
Use |
|
The Pages Router’s router is not available in the App Router. |
Import from |
|
Automatic static optimisation: the router is not ready until the client has hydrated. |
Wait for |
Active-link highlighting is wrong or the build says "outside of Suspense" |
|
Wrap the Client Component in |
Search crawlers or users see |
|
Decide existence outside any |
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 |
|
Invalid HTML: it renders nested |
Style the |
|
Two dynamic folders at the same position, such as |
Keep one dynamic folder per position; put the other route under a static segment ( |
|
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 ( |
Validate the params in the page and call |
|
A dynamic root segment such as |
|
A |
The router prefetches the URL as an RSC payload, gets JSON or a file, and falls back to a browser navigation. |
Use a plain |
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 ( |
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 |
Forward |
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.
| Version | Change |
|---|---|
13.0 |
|
15.0 (2024-10-21) |
|
15.3 |
|
15.4 |
|
16.0 (2025-10-21) |
Synchronous access to |
16.2 (2026-03-18) |
|
16.3 (2026-08-03) |
Instant Navigations (opt-in, needs |
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:
| In the books | Current approach |
|---|---|
Routing means files under |
The App Router is the recommended router: folders and |
Dynamic routes read |
In |
|
|
A persistent layout needs |
A |
A custom Express or Fastify server for routing rules. |
Next.js routes by file system; redirects, rewrites and headers go in |
Locale routing with the |
|
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,
pagemakes them public,layoutwraps 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 toreviews) 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,
routenext topage, two groups with one URL) fail the build. -
A soft navigation sends
RSC: 1and 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.