Dynamic Routes & Route Groups
|
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 covers the App Router in depth and compares it with the Pages Router in one section.
Wherever caching changes the behaviour of a dynamic route, the page names the model it is describing: the previous
model (the default, in which generateStaticParams and dynamicParams decide what is prerendered) or Cache
Components (cacheComponents: true, still opt-in, in which params are runtime data unless you supply samples).
Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS in a scratch copy of the
Bookshelf — the catalogue of books, authors and reviews that this section grows — and each one is followed by the
official page it derives from.
A static route like /about is one folder and one URL. A bookshop needs /en/books/3, /en/shelf/fiction/sci-fi
and a documentation tree of unknown depth, and it cannot create a folder for each of them. A dynamic segment is a
folder whose name is a placeholder: Next.js matches it against any value in that position and hands the value to your
code as a param. This page covers the three placeholder shapes, the three ways to read the value, how to type it,
how to prerender the values you know about, and the two folder conventions — route groups and private folders — that let you organise the tree without adding a URL segment. It assumes the folder basics of
Project Structure and the rendering vocabulary of
Introduction and Architecture.
The Bookshelf Routes at a Glance
The worked example of this page is one slice of the Bookshelf whose root layout is app/[locale]/layout.tsx. It has
two route groups, a private folder and one route of each dynamic shape:
app/
└── [locale]/ root segment; layout.tsx is the root layout
├── layout.tsx <html> and <body>, generateStaticParams for the locales
├── _components/ private folder: not routable
│ └── book-card.tsx
├── (shop)/ route group: not in the URL
│ ├── layout.tsx
│ ├── page.tsx /en
│ ├── books/
│ │ ├── new/page.tsx /en/books/new (static segment)
│ │ └── [id]/ dynamic segment
│ │ ├── layout.tsx
│ │ ├── page.tsx /en/books/3
│ │ └── reviews/page.tsx /en/books/3/reviews
│ ├── authors/[author]/[title]/page.tsx /en/authors/herbert/dune
│ └── shelf/[...path]/page.tsx /en/shelf/fiction/sci-fi
└── (marketing)/ route group: not in the URL
├── layout.tsx
├── about/page.tsx /en/about
└── docs/[[...slug]]/page.tsx /en/docs and /en/docs/a/b
app/api/books/[id]/route.ts /api/books/3 (Route Handler)
Source: Dynamic Route Segments
Worked Example: the Bookshelf URL Map
This section combines the shapes the way the full Bookshelf does: a [locale] root segment, four route groups with
different jobs, and one route of every dynamic shape. It was built with create-next-app on Next.js 16.3.8 and served
with next start on port 3712; every row below is an observed response.
app/
├── [locale]/
│ ├── layout.tsx root layout: <html lang>, locale guard, locales in generateStaticParams
│ ├── _components/ private folder: state-probe.tsx, segments.tsx
│ ├── (shop)/ catalogue; its layout renders the shop navigation
│ │ ├── layout.tsx
│ │ ├── page.tsx /en
│ │ ├── books/page.tsx /en/books
│ │ ├── books/[id]/layout.tsx
│ │ ├── books/[id]/page.tsx /en/books/1
│ │ └── shelf/[...slug]/page.tsx /en/shelf/fiction/sci-fi
│ ├── (account)/ signed-in area with its own layout
│ │ ├── layout.tsx
│ │ ├── my-reviews/page.tsx /en/my-reviews
│ │ └── settings/page.tsx /en/settings
│ ├── (marketing)/
│ │ ├── layout.tsx
│ │ └── docs/[[...slug]]/page.tsx /en/docs, /en/docs/routing
│ └── (checkout)/ no layout: checkout opts out of the shop navigation
│ └── checkout/page.tsx /en/checkout
└── api/params/[a]/[...rest]/route.ts /api/params/x/y/z (Route Handler)
The book page narrows the locale, parses the id into a number with the parseId helper of Typing Params, and sends a
non-canonical spelling such as 01 to /en/books/1 with permanentRedirect before it loads any data.
A curl loop over the production server gave these results:
| URL | Matched file (under app/[locale]/) |
params |
Result |
|---|---|---|---|
|
|
|
200, prerendered |
|
|
|
308 to |
|
|
|
404 from |
|
|
|
404 from the root layout’s locale guard |
|
|
|
200, account layout |
|
|
|
200, no shop navigation |
|
|
|
200 |
|
none (a catch-all needs a segment) |
- |
404 |
|
|
|
200 |
|
none: a group is never a segment, and static segments are case-sensitive |
- |
404 |
|
(redirect before matching) |
- |
308 to |
next build lists the same map, with the groups nowhere in it:
$ npx next build
...
Route (app)
┌ ○ /_not-found
├ /[locale]
│ ├ ● /en
│ └ ● /es
├ /[locale]/books
│ ├ ● /en/books
│ └ ● /es/books
├ /[locale]/books/[id]
│ ├ ● /en/books/1
│ ├ ● /en/books/2
│ ├ ● /es/books/1
│ └ ● /es/books/2
├ /[locale]/checkout
│ ├ ● /en/checkout
│ └ ● /es/checkout
├ /[locale]/docs/[[...slug]]
│ ├ ● /en/docs
│ ├ ● /en/docs/routing
│ ├ ● /es/docs
│ └ ● /es/docs/routing
├ /[locale]/my-reviews
│ ├ ● /en/my-reviews
│ └ ● /es/my-reviews
├ /[locale]/settings
│ ├ ● /en/settings
│ └ ● /es/settings
├ /[locale]/shelf/[...slug]
│ ├ ● /en/shelf/fiction/sci-fi
│ ├ ● /en/shelf/fiction/fantasy
│ ├ ● /en/shelf/café
│ └ ● [+3 more paths]
└ ƒ /api/params/[a]/[...rest]
/[locale]/my-reviews is ● without a dynamic segment of its own, because the root layout’s locale is above it;
/en/docs is listed because the optional catch-all returns { slug: [] } (see Catch-all Segments with generateStaticParams and dynamicParams).
Matching follows a fixed order at each level:
they never consume a URL segment"] strip --> level{"Next URL segment"} level --> st{"Static folder with
exactly this name?"} st -- yes --> descend["Descend into it"] st -- no --> dy{"A [param] folder?"} dy -- yes --> capture["Capture one segment as a string"] --> descend dy -- no --> ca{"A catch-all folder?"} ca -- yes --> rest["Capture the remaining segments
as a string array"] --> leaf ca -- no --> nf["No route: 404"] descend --> more{"More segments?"} more -- yes --> level more -- no --> leaf{"page or route file here,
or an optional catch-all below?"} leaf -- yes --> ok["Render with params"] leaf -- no --> nf
Source: Dynamic Route Segments, Route Groups, trailingSlash
The Three Dynamic Segment Shapes
A dynamic segment is created by wrapping a folder name in square brackets. The name inside the brackets is the key of
the param. Next.js passes the captured values as the params prop to layout, page, route and the
generateMetadata function of the segment and everything below it. The official documentation calls them dynamic
segments, and also path params, route params or URL params.
| Folder | Matches | Example URL and params |
params type |
|---|---|---|---|
|
exactly one segment |
|
|
|
one or more segments (a catch-all) |
|
|
|
zero or more segments (an optional catch-all) |
|
|
|
two dynamic segments |
|
|
The difference between the last two catch-all shapes is a single question: should the route without the parameter
exist? A catch-all [...slug] needs at least one segment. An optional catch-all [[...slug]] also answers the bare
path, which makes it the right shape for a documentation tree where /docs is the index and /docs/a/b is a deep
page, with a single page.tsx for both.
One Segment: [id]
The Bookshelf book page is the canonical [slug]-style route. The segment is called id because the data layer looks
books up by id, but the shape is identical to the official [slug] example:
import { notFound } from "next/navigation";
import { getBook } from "@/lib/books";
export default async function Page({ params }: PageProps<"/[locale]/books/[id]">) {
const { id } = await params;
const book = await getBook(id);
if (!book) notFound();
return (
<article>
<h2>{book.title}</h2>
<p>by {book.author}</p>
</article>
);
}
PageProps<'/[locale]/books/[id]'> is a globally available type helper (the next sections explain it). The segment
[locale] above is also a param, so the page receives { locale, id }, not just { id }: a route’s params holds
every dynamic segment from the root to the page.
Source: Dynamic Segments: Convention
Several Segments: Static Beats Dynamic
A static folder next to a dynamic one wins for its own URL. The Bookshelf has books/new/page.tsx beside
books/[id]/page.tsx:
$ curl -s localhost:3303/en/books/new | grep -o '<h2>[^<]*'
<h2>Add a book (static segment wins over [id])
$ curl -s localhost:3303/en/books/2 | grep -o '<title>[^<]*'
<title>Neuromancer - Bookshelf
So /en/books/new is the form page and is never captured as id = 'new'. In the same experiment a sibling
catch-all books/[...all] lost to [id] for /en/books/2 and won for /en/books/2/extra: a static folder beats a
dynamic one, which beats a catch-all, so you can add specific pages next to a general route without restructuring it.
Source: Dynamic Route Segments
Catch-all: […path]
The Bookshelf shelf is a hierarchy of unknown depth (fiction/sci-fi, non-fiction/science, and so on). A catch-all
route captures it as an array:
export default async function Page({ params }: PageProps<"/[locale]/shelf/[...path]">) {
const { path } = await params; // string[]
return (
<p data-segments={path.length}>
Shelf: {path.map((s) => decodeURIComponent(s)).join(" > ")}
</p>
);
}
$ curl -s localhost:3303/en/shelf/fiction/sci-fi | grep -o 'data-segments="[0-9]*">Shelf:.*</p>'
data-segments="2">Shelf: <!-- -->fiction > sci-fi</p>
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3303/en/shelf
404
/en/shelf is a 404 because a plain catch-all needs at least one segment. The decodeURIComponent call is
deliberate: in Next.js 16.3.8 the values are handed over percent-encoded (see Pitfalls and Errors).
Source: Catch-all Segments
Optional Catch-all: [[…slug]]
For the Bookshelf documentation, one file serves /en/docs and every deeper page:
export default async function Page({ params }: PageProps<"/[locale]/docs/[[...slug]]">) {
const { slug } = await params; // string[] | undefined
return <p data-slug={slug ? slug.join("/") : "index"}>Docs: {slug?.join("/") ?? "home"}</p>;
}
$ curl -s localhost:3303/en/docs | grep -o '<p data-slug[^>]*>'
<p data-slug="index">
$ curl -s localhost:3303/en/docs/a/b | grep -o '<p data-slug[^>]*>'
<p data-slug="a/b">
An optional catch-all claims the bare path, so a sibling docs/page.tsx would resolve to the same route. Next.js
refuses it at build time:
Error: You cannot define a route with the same specificity as an optional catch-all route
("/[locale]/docs" and "/[*]/docs/[[...slug]]").
Delete the extra page.tsx and branch on slug === undefined instead.
Source: Optional Catch-all Segments
Reading Params: await, use() and useParams
In the App Router, params is a Promise. The reason is performance: the value is runtime data in routes that are
not fully known at build time, and a Promise lets Next.js start rendering the static parts of the tree before the
value resolves. There are five places to read a param, and the right one depends on the kind of component.
page, layout, route, generateMetadata" --> a["const { id } = await params"] q -- "synchronous Server Component" --> b["pass the params Promise to a child
and await it there, inside Suspense"] q -- "Client Component page or layout" --> c["const { id } = use(params)"] q -- "Client Component deeper in the tree" --> d["const params = useParams()"] q -- "root segment, any Server Component
or shared server code" --> e["await locale() from next/root-params"]
| Where | How | Notes |
|---|---|---|
Async Server Component ( |
|
The default. |
Synchronous Server Component |
Pass |
React’s |
Client Component page or layout |
|
The Promise arrives as a prop and |
Any Client Component in the tree |
|
Returns an object of the active params (empty object when the route has none). No prop drilling. |
Root parameters anywhere on the server |
|
Server Components only; see Root Parameters and next/root-params. |
In Server Components: await
The book page above is the case: an async component, await params, destructure. The same holds for layouts
and for generateMetadata, which also receives params as a Promise:
import type { Metadata } from "next";
import { getBook } from "@/lib/books";
export async function generateMetadata({
params,
}: PageProps<"/[locale]/books/[id]">): Promise<Metadata> {
const { id } = await params;
const book = await getBook(id);
return { title: book ? `${book.title} - Bookshelf` : "Book not found" };
}
$ curl -s localhost:3303/en/books/2 | grep -o '<title>[^<]*'
<title>Neuromancer - Bookshelf
Route Handlers get their params through a second argument typed with RouteContext:
import { getBook } from "@/lib/books";
export async function GET(_request: Request, { params }: RouteContext<"/api/books/[id]">) {
const { id } = await params;
const book = await getBook(id);
if (!book) return Response.json({ error: "Book not found" }, { status: 404 });
return Response.json(book);
}
$ curl -s localhost:3303/api/books/2
{"id":"2","title":"Neuromancer","author":"William Gibson","priceCents":899}
$ curl -s -w " %{http_code}\n" localhost:3303/api/books/9
{"error":"Book not found"} 404
In Client Component Pages: use()
A Client Component page receives the same Promise. React’s use API unwraps it; because a Client Component cannot be
async, await is not available. The Bookshelf reviews page is a Client Component, for instance because it will hold
interactive state:
"use client";
import { use } from "react";
export default function ReviewsPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { id } = use(params);
return <h2>Reviews of book {id}</h2>;
}
$ curl -s localhost:3303/es/books/1/reviews | grep -o '<h2>[^<]*<!-- -->[^<]*'
<h2>Reviews of book <!-- -->1
In the Client Tree: useParams
use(params) only works where the Promise was passed down as a prop. A breadcrumb inside the layout needs the same
values without receiving a prop, which is what the useParams hook is for. It is a Client Component hook that
returns the params of the current URL:
"use client";
import { useParams } from "next/navigation";
export function Crumbs() {
const params = useParams<{ locale: string; id: string }>();
return (
<nav>
{params.locale} / books / {params.id}
</nav>
);
}
$ curl -s localhost:3303/es/books/1/reviews | grep -o '<nav>.*</nav>'
<nav>es<!-- --> / books / <!-- -->1</nav>
useParams returns an object whose keys are the active dynamic segments; each value is a string, or an array of
string for catch-all segments; a route with no dynamic segments gives {}. In the Pages Router the same hook
returns null on the first render until the router is ready (it was introduced in 13.3.0).
Source: useParams
With Cache Components: runtime params need Suspense
Under the previous model, awaiting params anywhere is fine. With cacheComponents: true the rule changes, and the
rule depends on whether generateStaticParams covers the route. Without it, the param values are unknown while
prerendering, so reading them is runtime data and must sit inside a <Suspense> boundary or the build fails.
The documented pattern is to pass the Promise down and await it in a child:
import { Suspense } from "react";
export default function Page({ params }: PageProps<"/[locale]/docs/[[...slug]]">) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Docs params={params} />
</Suspense>
);
}
async function Docs({ params }: { params: PageProps<"/[locale]/docs/[[...slug]]">["params"] }) {
const { slug } = await params; // string[] | undefined
return <p data-slug={slug ? slug.join("/") : "index"}>Docs: {slug?.join("/") ?? "home"}</p>;
}
Without the Suspense boundary the same build stops with this error (verified on 16.3.8):
Error: Route "/[locale]/docs/[[...slug]]": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>`
prevents the route from being prerendered ...
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic
useParams follows the same rule: when every param of the route is known at build time (a static route, or one
covered by generateStaticParams) it needs no boundary; for params not covered it suspends, and the call site needs
<Suspense> or the build fails with blocking-prerender-client-hook. In a layout, do not await params at the top
level: that blocks the layout from being prerendered. Pass the Promise to the component that needs it instead.
Typing Params
Route params values are typed string, string[] or undefined because the actual value is whatever a visitor
types in the address bar. The table lists the TypeScript shape for each folder:
| Route | params type |
|---|---|
|
|
|
|
|
|
|
|
You can write those by hand (params: Promise<{ slug: string }>), but the idiomatic way is to let Next.js derive
them from the folder tree. PageProps, LayoutProps and RouteContext are globally available helpers, generated
by next dev, next build or next typegen (the command was introduced in 15.5). They take the route literal, give
autocomplete and strict keys, and they know the Promise shape, so a typo or a leftover synchronous access fails the
type check:
export default async function Page(props: PageProps<"/[locale]/typo/[id]">) {
const { slug } = await props.params;
const sync = props.params.id;
return <p>{slug}{sync}</p>;
}
app/[locale]/(shop)/typo/[id]/page.tsx(2,11): error TS2339: Property 'slug' does not exist on type '{ locale: string; id: string; }'.
app/[locale]/(shop)/typo/[id]/page.tsx(3,29): error TS2339: Property 'id' does not exist on type 'Promise<{ locale: string; id: string; }>'.
Failed to type check.
Static routes resolve params to {}. For a layout use LayoutProps<'/[locale]'> (which also types children and
parallel-route slots) and for a Route Handler RouteContext<'/api/books/[id]'>.
Why Every Param Is a String
A URL path is text, and nothing in a folder name such as [id] says "number", so Next.js never converts: id is
'1', never 1. The contract also runs the other way. Returning numbers from generateStaticParams passes the
TypeScript step and then stops the build:
$ npx next build
...
Finished TypeScript in 445ms ...
Collecting page data using 9 workers ...
Error: A required parameter (id) was not provided as a string received number in generateStaticParams for /[locale]/books/[id]
> Build error occurred
A string for a catch-all fails the same way (A required parameter (slug) was not provided as an array received
string). Map ids with String(book.id).
The helper types come from .next/types/routes.d.ts, which next build, next dev and next typegen write. It is
keyed by the route pattern without route groups:
type LayoutRoutes = "/[locale]" | "/[locale]/books/[id]"
interface ParamMap {
"/[locale]": { "locale": string; }
"/[locale]/books/[id]": { "locale": string; "id": string; }
"/[locale]/docs/[[...slug]]": { "locale": string; "slug"?: string[]; }
// ...
}
So every group layout of the Bookshelf is typed LayoutProps<'/[locale]'>, like the root layout (there is no
(shop) entry to choose), and the literal for a page is the pattern printed by next build.
Source: Layout Props Helper, generateStaticParams
Narrowing Params: One Parsing Module for Every Route
A param is typed string, but most have a much smaller set of valid values. The documented approach is runtime
validation with an assertion function: invalid values go to notFound(), and the rest of the code works with the
narrower type. This is also the security rule for the whole page: a param is user input. Validation scattered over
pages drifts, so the Bookshelf keeps the rules in one server module that pages, layouts, generateMetadata and Route
Handlers call before touching data. The root layout calls assertLocale, so /fr/… is a 404 before any page runs;
the book page calls parseId (a zod schema such as z.string().regex(…) works just as well):
import { notFound } from "next/navigation";
import { type Locale, isLocale } from "@/lib/i18n";
// A route param is always a string: narrow it once, at the edge of the route.
export function assertLocale(value: string): asserts value is Locale {
if (!isLocale(value)) notFound();
}
// "3" -> 3. Anything else ("abc", "1e3", "-1", "", "99999999999") -> notFound().
export function parseId(value: string): number {
if (!/^[0-9]{1,9}$/.test(value)) notFound();
return Number(value);
}
// Page params arrive percent-encoded: decode each segment once, then validate.
export function decodeSegments(segments: string[]): string[] {
try {
return segments.map((s) => decodeURIComponent(s));
} catch {
notFound(); // a malformed escape
}
}
Why a pattern and not Number(id)? Number('1e3') is 1000, Number('') is 0 and parseInt('1abc', 10) is 1:
each turns a junk URL into a real book page and a separate cache entry. The nine-digit cap stays below
Number.MAX_SAFE_INTEGER. Spellings the pattern does
accept, such as 01, are sent to the canonical URL with permanentRedirect (308), so search engines and the ISR
cache see one URL per book. Narrowing works because notFound() and permanentRedirect() throw and are typed never.
Check per-page params (id, slug) in the page, not a layout: a layout does not re-run on a soft navigation between
its children. And give generateMetadata the same guard, or it can build a <title> from an invalid value.
Never interpolate a raw param into a query string, a file path or a command: a slug captured into an fs path allows
.. tricks (see Special Characters in Segments: Encoding and Decoding), and a slug concatenated into a GROQ or SQL query allows injection. The consulted
e-commerce book, for instance, interpolates the slug into a CMS query; the correct form passes it as a query parameter.
The same generated types also check the other direction: with typedRoutes: true, a link such as
(a typo for /${locale}/book/${id} books) fails the TypeScript step of next build with TS2322, while
passes. Prefer template literals for links to dynamic routes; a concatenated string is
not checked and needs an /${locale}/books/${id} as Route cast. The option is covered in
Configuration, TypeScript and Environment Variables.
Special Characters in Segments: Encoding and Decoding
The browser (or curl) percent-encodes what a URL cannot carry: a space becomes %20, é the UTF-8 bytes %C3%A9,
and a slash inside a value must be sent as %2F. The router splits the path on real / characters before anything
is decoded, so %2F never creates a segment. What your code then receives depends on where it runs. The shelf page
prints the raw param and the result of decodeSegments:
import { decodeSegments } from "@/lib/params";
export default async function Shelf({ params }: PageProps<"/[locale]/shelf/[...slug]">) {
const { slug } = await params; // string[]
return (
<pre>
raw={JSON.stringify(slug)} decoded={JSON.stringify(decodeSegments(slug))}
</pre>
);
}
$ for u in "sci%20fi" "a%2Fb/c" "café" "a+b" "..%2F..%2Fetc"; do
curl -s "localhost:3712/en/shelf/$u" | grep -o '<pre>.*</pre>'; done
<pre>raw=["sci%20fi"] decoded=["sci fi"]</pre>
<pre>raw=["a%2Fb","c"] decoded=["a/b","c"]</pre>
<pre>raw=["caf%C3%A9"] decoded=["café"]</pre>
<pre>raw=["a%2Bb"] decoded=["a+b"]</pre>
<pre>raw=["..%2F..%2Fetc"] decoded=["../../etc"]</pre>
$ curl -s "localhost:3712/api/params/x%20y/a%2Fb/caf%C3%A9" # returns Response.json(await ctx.params)
{"a":"x y","rest":["a/b","café"]}
$ curl -s -o /dev/null -w "%{http_code}\n" "localhost:3712/en/shelf/100%25"
500
(HTML entities and React’s <!-- -→ markers are removed from the output.)
| Segment | App Router page or layout | App Router Route Handler | Pages Router params/query |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 500, |
|
|
|
HTTP 500 |
HTTP 500 |
HTTP 400 |
The rules that follow:
-
Decode once, in pages. Decoding twice corrupts values containing
%; Route Handlers and the Pages Router already decode. -
A plus sign in a path is a plus. "Plus means space" applies to query strings only.
-
Keep
%out of slugs. A page answers500for%25before your code runs. Generate slugs from[a-z0-9-]. -
Validate after decoding.
curl --path-as-is /en/shelf/a/../barrives as['b'](dot segments are resolved), but..%2F..%2Fetcis one segment that decodes to../../etc. Never join a decoded param into a file path. -
Return decoded values from
generateStaticParams. Thecaféshelf, generated from{ slug: ['café'] }, was a cacheHITfor both/en/shelf/caf%C3%A9and the raw UTF-8 request. -
Encode each segment in links with
encodeURIComponent, so a/or?in a value cannot change the route.
generateStaticParams and dynamicParams
A dynamic route is rendered per request unless you tell Next.js which values to prerender. That is the job of
generateStaticParams, an exported function (from a page, a layout or a Route Handler) that returns an array of
param objects, one per route variant. It replaces the Pages Router’s getStaticPaths. It runs at next build
before the corresponding layouts or pages are generated, it runs on navigation in next dev, and it is not
called again during revalidation (ISR).
| Route | Returns |
|---|---|
|
|
|
|
|
|
Note that catch-all params are arrays, one array per URL. The Bookshelf page below prerenders the first two books:
// Only the first two books are prerendered at build time.
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }];
}
The parent layout app/[locale]/layout.tsx returns the locales, so Next.js builds the cross product:
Route (app)
┌ ○ /_not-found
├ /[locale]
│ ├ ● /en
│ └ ● /es
├ /[locale]/authors/[author]/[title]
│ ├ ● /en/authors/herbert/dune
│ ├ ● /en/authors/herbert/children-of-dune
│ ├ ● /en/authors/gibson/neuromancer
│ └ ● [+3 more paths]
├ /[locale]/books/[id]
│ ├ ● /en/books/1
│ ├ ● /en/books/2
│ ├ ● /es/books/1
│ └ ● /es/books/2
├ ƒ /[locale]/books/[id]/reviews
├ /[locale]/books/new
│ └ ...
├ ƒ /[locale]/docs/[[...slug]]
├ /[locale]/shelf/[...path]
│ └ ...
└ ƒ /api/books/[id]
reviews and docs have no generateStaticParams of their own (a Client Component page cannot export one), so
they stay ƒ (rendered on demand).
Multiple Segments: Top Down or Bottom Up
You can generate params for segments above the current file, never below. Two approaches work. Bottom up: the
leaf page returns every combination at once. Top down: the parent layout returns its own segment and the child’s
generateStaticParams runs once per parent value, receiving what the parent generated:
// app/[locale]/(shop)/authors/[author]/layout.tsx
export function generateStaticParams() {
return [{ author: "herbert" }, { author: "gibson" }];
}
export default function AuthorLayout({ children }: LayoutProps<"/[locale]/authors/[author]">) {
return <div>{children}</div>;
}
// app/[locale]/(shop)/authors/[author]/[title]/page.tsx
const titles: Record<string, string[]> = {
herbert: ["dune", "children-of-dune"],
gibson: ["neuromancer"],
};
export function generateStaticParams({ params }: { params: { author: string } }) {
return (titles[params.author] ?? []).map((title) => ({ title }));
}
The build table above lists /en/authors/herbert/dune, /en/authors/herbert/children-of-dune and
/en/authors/gibson/neuromancer (and the three es twins). Inside a nested generateStaticParams a root parameter
can also be read directly with its next/root-params getter instead of destructuring it from params.
Previous Model: Subsets, dynamicParams and Request-time Rendering
What happens for a value that generateStaticParams did not return is controlled by the dynamicParams route
segment config (it replaces the fallback: true | false | 'blocking' option of the Pages Router’s getStaticPaths):
-
true(the default): the page is rendered on demand the first time and the result is cached. -
false: the page answers 404 for every value not returned bygenerateStaticParams.
The Bookshelf book page prerenders ids 1 and 2. With the default dynamicParams = true, id 3 renders on demand, and
the second request is already a cache hit. Setting dynamicParams = false makes id 3 a 404:
// Previous model only: false turns every id outside generateStaticParams into a 404.
// It is not allowed when cacheComponents is on.
export const dynamicParams = false;
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3303/en/books/1
200
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3303/en/books/3
404
To prerender nothing at build time and everything on first visit, return an empty array. The first request is a
cache MISS that renders and stores the page, the second a HIT (the response headers below come from a build whose
generateStaticParams returns []):
$ for i in 1 2; do curl -sI localhost:3303/en/books/2 | grep -i "x-nextjs-cache"; done
x-nextjs-cache: MISS
x-nextjs-cache: HIT
You must always return an array, even an empty one; otherwise the route is dynamically rendered. (export const
dynamic = 'force-static' is the other documented way to get the same first-visit ISR.)
Cache Components: Samples, Not Lists
With cacheComponents: true the contract of generateStaticParams changes. It supplies samples: param values used
at build time to run the route and validate that it accesses runtime data correctly. The rules, all
verified on 16.3.8:
-
generateStaticParamsmust return at least one param. An empty array is a build error (empty-generate-static-params), because with no sample Next.js cannot check that the route does not readcookies(),headers()orsearchParamsincorrectly. -
dynamicParamsis not available: exporting it fails the build, whatever its value. -
Values returned by
generateStaticParamsare prerendered into complete static HTML. Other values are runtime params: the static shell is sent, then the param-dependent content streams in, and pages rendered this way are saved after a successful first request. -
Build-time validation covers only the code paths the samples execute. A branch reached by other values (for example
slug.startsWith('private-')readingcookies()) is validated on the first request instead, so wrap such branches in<Suspense>.
Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.
The Bookshelf book page under Cache Components. generateStaticParams keeps the two samples, the param read moves
into a child under <Suspense>, and notFound() still works:
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { getBook } from "@/lib/books";
// Required with Cache Components: at least one sample (an empty array is a build error).
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }];
}
export default function Page({ params }: PageProps<"/[locale]/books/[id]">) {
return (
<Suspense fallback={<p>Loading book...</p>}>
<Book params={params} />
</Suspense>
);
}
async function Book({ params }: { params: PageProps<"/[locale]/books/[id]">["params"] }) {
const { id } = await params;
const book = await getBook(id);
if (!book) notFound();
return (
<article>
<h2>{book.title}</h2>
<p>by {book.author}</p>
</article>
);
}
$ for u in /en/books/1 /en/books/3 /en/books/99; do echo "== $u"; curl -s -D - -o /dev/null localhost:3303$u | grep -i "^HTTP\|x-nextjs-cache"; done
== /en/books/1
HTTP/1.1 200 OK
x-nextjs-cache: HIT
== /en/books/3
HTTP/1.1 200 OK
x-nextjs-cache: MISS
== /en/books/99
HTTP/1.1 200 OK
x-nextjs-cache: MISS
Id 1 is a cache hit (prerendered), id 3 is rendered on the first request, and id 99 answers 200 — not 404 — because the shell, with its Loading book... fallback, has already been sent when notFound() runs inside the
streamed boundary. Handle this with a noindex meta tag or by validating before the boundary (see Pitfalls and Errors).
A layout’s generateStaticParams can supply the samples for everything below it, but in this experiment a Client
Component page below a layout that had the samples still failed the Cache Components build with the unresolved
[id] placeholder. The fix was a thin Server Component page.tsx that exports generateStaticParams and passes the
params Promise to the Client Component:
// app/[locale]/(shop)/books/[id]/reviews/page.tsx
import { Reviews } from "./reviews";
export function generateStaticParams() {
return [{ id: "1" }, { id: "2" }];
}
export default function Page({ params }: PageProps<"/[locale]/books/[id]/reviews">) {
// Pass the promise itself; the Client Component unwraps it with use().
return <Reviews params={params} />;
}
// app/[locale]/(shop)/books/[id]/reviews/reviews.tsx
"use client";
import { use } from "react";
export function Reviews({ params }: { params: Promise<{ locale: string; id: string }> }) {
const { id } = use(params);
return <h2>Reviews of book {id}</h2>;
}
The route table of that build marks the routes ◐ Partial Prerender (static shell plus streamed content) instead of
●. With partialPrefetching: true as well, 16.3 adds ISR with App Shell: a visit to a URL whose params were not
generated is answered instantly with the App Shell and upgraded in the background; later visits get the cached full
page. It is the Cache Components equivalent of fallback: true in the Pages Router.
generateStaticParams?"} known -- yes --> html["Serve the prerendered page
(cache HIT)"] known -- no --> model{"cacheComponents?"} model -- "no (previous model)" --> dp{"dynamicParams"} dp -- "true (default)" --> render["Render on demand,
cache for next time"] dp -- "false" --> nf["404"] model -- "yes (Cache Components)" --> shell["Send the static shell,
stream the param-dependent content,
save after a successful first render"]
Catch-all Segments with generateStaticParams and dynamicParams
A catch-all answers URLs of every depth, so here generateStaticParams and dynamicParams interact least
intuitively. The caching mechanics are covered in
Static Site Generation: dynamicParams,
Static Site Generation: with Cache Components and
Incremental Static Regeneration;
this subsection shows only which URLs exist. Each entry describes one full URL as an array; for an optional
catch-all, the empty array stands for the bare path:
// app/[locale]/(shop)/shelf/[...slug]/page.tsx
export function generateStaticParams() {
return [{ slug: ["fiction", "sci-fi"] }, { slug: ["fiction", "fantasy"] }, { slug: ["café"] }];
}
// app/[locale]/(marketing)/docs/[[...slug]]/page.tsx: [] prerenders /en/docs itself
export function generateStaticParams() {
return [{ slug: [] }, { slug: ["routing"] }];
}
At request time the bare path still arrives as slug: undefined, not [] (the page printed data-slug="null" for
/en/docs), so branch on !slug?.length. Then both pages got export const dynamicParams = false;:
$ for u in /en/shelf/fiction/sci-fi /en/shelf/fiction /en/shelf/fiction/sci-fi/x /en/docs /en/docs/a; do
curl -s -o /dev/null -w "%{http_code} $u\n" "localhost:3712$u"; done
200 /en/shelf/fiction/sci-fi
404 /en/shelf/fiction
404 /en/shelf/fiction/sci-fi/x
200 /en/docs
404 /en/docs/a
With dynamicParams = false a catch-all is not a prefix match: the parent /en/shelf/fiction and the child
/en/shelf/fiction/sci-fi/x of a generated path are both 404. If the parent levels of a hierarchy are pages too,
return them as samples (['fiction'] as well as ['fiction', 'sci-fi']). The same build with Cache Components
(cacheComponents: true, no dynamicParams) marked the sample paths ○ and the routes ◐; /en/shelf/fiction
answered x-nextjs-cache: MISS and then HIT, /en/docs was a HIT and /en/shelf a 404:
| Request | Previous model, dynamicParams = true |
Previous model, dynamicParams = false |
Cache Components |
|---|---|---|---|
A generated array |
Prerendered |
Prerendered |
Prerendered |
Its parent, a child, or an unrelated path |
Rendered on first request, cached |
404 |
Rendered on first request and saved ( |
Bare path of an optional catch-all ( |
Prerendered if |
404 unless |
Prerendered if |
Under Cache Components, unknown catch-all paths can only be rejected in your code (notFound() after validating the
decoded segments, with the status caveat above). Build-time validation covers only the branches the samples execute,
so include one sample per depth that takes a different code path.
Route Groups
A route group is a folder wrapped in parentheses, (folderName). It is for organisation only and is not part of
the URL. The Bookshelf slice has two groups under [locale]: (shop) holds the catalogue, books and shelf;
(marketing) holds about and docs. Both groups share the [locale] segment and the URL space, yet each has its
own layout.tsx:
// app/[locale]/(shop)/layout.tsx
export default function ShopLayout({ children }: LayoutProps<"/[locale]">) {
return (
<section data-group="shop">
<h1>Bookshelf shop</h1>
{children}
</section>
);
}
// app/[locale]/(marketing)/layout.tsx
export default function MarketingLayout({ children }: LayoutProps<"/[locale]">) {
return <section data-group="marketing">{children}</section>;
}
$ for u in /en /en/books/1 /en/about /en/docs; do echo "== $u"; curl -s localhost:3303$u | grep -o 'data-group="[a-z]*"'; done
== /en
data-group="shop"
== /en/books/1
data-group="shop"
== /en/about
data-group="marketing"
== /en/docs
data-group="marketing"
See Project Structure: Route Groups for the folder-organisation
view, including the Bookshelf’s (account) group; this page concentrates on what groups mean for routing.
Per-group Layouts and Opting Out of a Layout
A route inherits every layout on its folder path, and there is no "skip the parent layout" switch. A route group is
how you give a layout to some siblings without inventing a URL segment. In the URL map, (shop) holds the shop
navigation, (account) the account navigation (in the full Bookshelf it also runs the session check of the Data
Access Layer), and (marketing) a plain wrapper. (checkout) has no layout: to remove checkout from the shop chrome
you do not configure checkout, you move it next to (shop) instead of inside it. The probe confirms it: /en/checkout
returns 200 with no data-group="shop" element.
The same move scopes loading.tsx, error.tsx and not-found.tsx to one route. Groups are only needed at the
same level: books/new/page.tsx never sees books/[id]/layout.tsx, because it is not below that folder.
Source: Route Groups: Use cases, layout.js
What Survives a Navigation
Groups and dynamic segments decide which layouts React keeps mounted on a client-side navigation. To see it, the
(shop), books/[id] and (account) layouts each render a StateProbe, a Client Component (in
app/[locale]/_components/) whose button shows a useState click counter. In Chromium against the production server, a script clicked the shop
counter twice and the book counter once, set window.__marker, then followed the links:
shop-layout: 2 | book-layout: 1 | marker=alive | /en/books/1 start
shop-layout: 2 | book-layout: 0 | marker=alive | /en/books/2 Link to another book
account-layout: 0 | marker=alive | /en/my-reviews Link into another group
shop-layout: 0 | marker=alive | /en/books Link back into (shop)
A final router.push('/es/books') kept window.__marker and updated <html lang> to es in place.
-
A new
idremounts the[id]layout; the(shop)layout above it keeps its state. Put state that must survive across books above[id]. (The Pages Router does not reset state on a param change, see Pages Router: Dynamic Routes.) -
Leaving a group unmounts its layout. The navigation stays soft, but the shop counter is back to 0 on return.
-
Changing a root param is soft while the root layout file stays the same.
Source: Layouts and Pages, Route Groups: Caveats
Organising by Team or Feature
A group costs nothing in the URL, so it can follow the organisation chart: one group per team or feature, each with
its own layout and error UI, and a CODEOWNERS entry per group:
# .github/CODEOWNERS
/app/*/(shop)/ @bookshelf/catalogue-team
/app/*/(account)/ @bookshelf/identity-team
/app/*/(marketing)/ @bookshelf/content-team
/app/*/(checkout)/ @bookshelf/payments-team
(CODEOWNERS does not support [ ] ranges, hence the *.) The router’s only constraint is that two groups must not
define the same URL.
The segment hooks do report group folders, which matters for breadcrumbs and analytics labels:
"use client";
import { useSelectedLayoutSegments } from "next/navigation";
export function Segments() {
const all = useSelectedLayoutSegments(); // includes route groups such as "(shop)"
const visible = all.filter((s) => !s.startsWith("("));
return <output data-all={JSON.stringify(all)} data-visible={JSON.stringify(visible)} />;
}
$ for u in /en/books/1 /en/shelf/fiction/sci-fi /en/my-reviews; do curl -s localhost:3712$u | grep -o '<output[^>]*>'; done
<output data-all="["(shop)","books","1"]" data-visible="["books","1"]">
<output data-all="["(shop)","shelf","fiction/sci-fi"]" data-visible="["shelf","fiction/sci-fi"]">
<output data-all="["(account)","my-reviews"]" data-visible="["my-reviews"]">
The documented filter removes the groups; a catch-all is one joined string (fiction/sci-fi). The segment hooks
are covered in Linking and Navigation.
Multiple Root Layouts
Delete the top-level layout.tsx and give each group a layout that renders <html> and <body>. Each group is then a
separate application shell. The experiment used a (shop) and a (marketing) group at the top of app/:
// app/(shop)/layout.tsx
export default function ShopRoot({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body data-root="shop">{children}</body>
</html>
);
}
// app/(marketing)/layout.tsx
export default function MarketingRoot({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body data-root="marketing">{children}</body>
</html>
);
}
$ for u in /cart /blog /about /; do curl -s localhost:3303$u | grep -o '<body[^>]*>'; done
<body data-root="shop">
<body data-root="marketing">
<body data-root="marketing">
<body data-root="marketing">
(/cart lives in (shop); /blog, /about and / live in (marketing).) Three caveats are real constraints, not
style advice:
-
Full page load. Navigating between routes that use different root layouts is a hard navigation, not a client-side one.
-
Conflicting paths. Two groups must not resolve to the same URL. With
(shop)/about/page.tsxand(marketing)/about/page.tsxthe build fails:Two routes that resolve to /aboutError: You cannot have two parallel pages that resolve to the same path. Please check /(marketing)/about and /(shop). -
No top-level layout. Without
app/layout.tsx, the home route/must live inside one of the groups.
Also, a root layout is only a root layout when nothing above it declares <html>. The first attempt at the
Bookshelf experiment kept a stale app/layout.tsx next to app/[locale]/layout.tsx; next/root-params then had no
locale export, because [locale] was not above the root layout. Remove the old file so that app/[locale]/layout.tsx
is the root layout.
Source: Route Groups, Root layout
Shared Root Layout or Multiple Root Layouts: the Cost
To measure the "full page load" caveat, a second variant deleted app/[locale]/layout.tsx and gave each group a
layout with <html> and <body> (and the locale generateStaticParams). The same browser script ran against both
builds; the navigation entry is performance.getEntriesByType('navigation')[0].name, which changes only when the
browser loads a new document:
One shared root layout ([locale]/layout.tsx)
/en/books/2 -> /en/my-reviews marker=alive navigation entry /en/books/1 (soft)
One root layout per group ((shop) and (account) render <html>)
/en/books/1 -> /en/books/2 marker=alive navigation entry /en/books/1 (soft: same root)
/en/books/2 -> /en/my-reviews marker=undefined navigation entry /en/my-reviews (hard: new document)
Crossing root layouts discarded the JavaScript state, the React tree and the router cache, like a plain <a href>.
So keep one root layout for areas users move between all the time (the Bookshelf’s catalogue, account and checkout),
and reserve one root layout per group for areas they rarely cross, such as a public site and an admin console that
needs a different <html>, theme or analytics. With several roots, a root parameter missing from some of them is
typed string | undefined by next/root-params.
The file-level rules are in
Layouts, Templates and Special Files.
Source: Route Groups: Caveats, Root layout
Private Folders
A folder whose name starts with an underscore, _folderName, is a private folder: the routing system ignores it
and every subfolder. The Bookshelf keeps shared UI next to the routes that use it in app/[locale]/_components/:
import Link from "next/link";
import type { Book } from "@/lib/books";
export function BookCard({ book, locale }: { book: Book; locale: string }) {
return (
<li>
<Link href={`/${locale}/books/${book.id}`}>{book.title}</Link> - {book.author}
</li>
);
}
Files in app/ are safely colocated by default (only page and route files create URLs), so a private folder is
not required for colocation. It is useful to separate UI code from routing code, to organise internal files
consistently, to sort and group files in editors, and to avoid clashes with future Next.js file conventions. To make
a URL segment that really starts with an underscore, write the folder with %5F, the URL-encoded underscore:
%5FfolderName. A private folder never appears in the build route table.
Source: Private folders; the Project Structure page compares the three organisation strategies.
Root Parameters and next/root-params
The dynamic segments that sit above the root layout are root parameters. The Bookshelf’s [locale] is one,
because its root layout is app/[locale]/layout.tsx. Normally a deeply nested component gets a param only if every
ancestor passes the params prop down. The next/root-params module (shipped in 16.3) removes that: it exports one
async getter per root parameter, named after the folder, and any Server Component or server-side utility can import
it:
import { notFound } from "next/navigation";
import { locale } from "next/root-params";
import { locales, isLocale } from "@/lib/i18n";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function RootLayout({ children }: LayoutProps<"/[locale]">) {
const current = await locale();
if (!current || !isLocale(current)) notFound();
return (
<html lang={current}>
<body>{children}</body>
</html>
);
}
import { locale as rootLocale } from "next/root-params";
// ... inside the async Page component:
const loc = await rootLocale(); // "en" or "es"
// <p>by {book.author} (locale: {loc})</p>
$ curl -s localhost:3303/en/books/3 | grep -o '<p>by.*</p>'
<p>by <!-- -->Stanislaw Lem<!-- --> (locale: <!-- -->en<!-- -->)</p>
The getter returns a Promise: string for [id], string[] for [...path] and string[] | undefined for
[[...path]]. When an app has several root layouts, a getter is typed for all of them, so a parameter missing from
some roots is string | undefined. The types are generated by next dev, next build or next typegen, like the
PageProps helpers (.next/types/root-params.d.ts declares locale(): Promise<string> for the Bookshelf).
| Situation | Behaviour |
|---|---|
Server Components |
Supported anywhere: layouts, pages, shared utilities. No |
Client Components, Server Actions, Route Handlers |
Not supported. Support for Route Handlers is planned for a future release. |
Segment names |
Must be valid JavaScript identifiers. A kebab-cased segment such as |
|
Required only with Cache Components, where every root parameter needs at least one value. A nested |
|
Next.js tracks which getters a cached function calls, and only those root parameters enter the cache key, so entries are not split across unrelated values. |
|
Calling a getter inside it throws at runtime. Use |
The earlier experimental unstable_rootParams was removed in 16.0; next/root-params shipped in 16.3 as its
replacement.
Source: next/root-params
Pages Router: Dynamic Routes
The Pages Router uses the same bracket convention on files or folders under pages/, with a different API around it.
The value is not a prop: it lives in the query object of useRouter() and in the params of the data functions. The
experiments for this section built the same Bookshelf book in a pages/ project:
import type { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { getBook, type Book } from "@/lib/books";
export const getStaticPaths = (async () => {
return {
paths: [{ params: { id: "1" } }, { params: { id: "2" } }],
fallback: "blocking", // false | true | "blocking"
};
}) satisfies GetStaticPaths;
export const getStaticProps = (async ({ params }) => {
const book = await getBook(String(params?.id));
if (!book) return { notFound: true };
return { props: { book }, revalidate: 60 };
}) satisfies GetStaticProps<{ book: Book }>;
export default function BookPage({ book }: { book: Book }) {
const router = useRouter();
// router.query.id is a string | string[] | undefined
return (
<article>
<h1>{book.title}</h1>
<p>by {book.author} (id from router.query: {String(router.query.id)})</p>
</article>
);
}
Route (pages) Revalidate Expire
┌ ○ /
├ ○ /404
├ /books/[id] 1m 1y
│ ├ ● /books/1
│ └ ● /books/2
├ ƒ /docs/[[...slug]]
└ ○ /shelf/[...path]
$ for u in /books/1 /books/3 /books/99; do echo "== $u"; curl -s -D - -o /dev/null localhost:3303$u | grep -i "^HTTP\|x-nextjs-cache"; done
== /books/1
HTTP/1.1 200 OK
x-nextjs-cache: HIT
== /books/3
HTTP/1.1 200 OK
x-nextjs-cache: MISS
== /books/99
HTTP/1.1 404 Not Found
x-nextjs-cache: MISS
Id 1 was prerendered; id 3 was rendered on the first request (fallback: "blocking") and then cached; id 99 hit
notFound: true and answered a real 404. The catch-all and optional catch-all work the same way:
pages/shelf/[...path].tsx and pages/docs/[[...slug]].tsx, with params.path / params.slug in the data functions.
Two typing and timing differences matter:
-
Types. In
getServerSidePropsandgetStaticProps,paramsvalues arestring | string[] | undefined(ParsedUrlQuery), even for a plain[id]; the App Router types a plain segment asstring. Code that declares astring[] | nullprop must normalise, as the experiment’sgetServerSidePropsdoes withtypeof slug === "string" ? [slug] : (slug ?? null). -
Timing. On a page that is statically optimised (no data function),
router.queryis an empty object during prerendering, and is filled after hydration. The experiment’spages/shelf/[...path].tsxrenderedready=false path=-in the HTML. Readrouter.queryonly whenrouter.isReadyis true (in an effect), and prefer the data functions on the server.
Params, Query and Encoding in the Pages Router
In the Pages Router the route params and the query string arrive in one object. The URL map app also had a
pages/legacy/[id].tsx whose getServerSideProps returned ctx.params and ctx.query as props, and which rendered
them next to useRouter().query; a sibling pages/legacy/[...slug].tsx rendered its ctx.params:
$ for u in "/legacy/1?id=2&sort=new" "/legacy/caf%C3%A9" "/legacy/100%25" "/legacy/a/b%2Fc"; do
curl -s "localhost:3712$u" | grep -o '<pre>.*</pre>'; done
<pre>params={"id":"1"} query={"id":"1","sort":"new"} router.query={"id":"1","sort":"new"}</pre>
<pre>params={"id":"café"} query={"id":"café"} router.query={"id":"café"}</pre>
<pre>params={"id":"100%"} query={"id":"100%"} router.query={"id":"100%"}</pre>
<pre>catch-all params={"slug":["a","b/c"]}</pre>
Four behaviours differ from the App Router:
-
The route param wins over a query parameter of the same name.
/legacy/1?id=2givesquery.id === '1', and the?id=2is lost. In the App RouterparamsandsearchParamsare separate props and never collide. -
Values are decoded:
'café','100%', andb%2Fcas one segment'b/c', where an App Router page receives'caf%C3%A9'(see Special Characters in Segments: Encoding and Decoding). Check before porting adecodeURIComponentcall between the routers. -
State is not reset when only the param changes.
/legacy/1to/legacy/2renders the same component, so React keeps its state; the documented fixes are akeyonComponentinpages/_app.tsxor an effect keyed onrouter.query.id. In the App Router a layout under[id]remounts per id on its own. -
The
next/navigationhooks become nullable when both routers are present. With apages/directory, Next.js adds/// <reference types="next/navigation-types/compat/navigation" />tonext-env.d.ts, which typesuseParams,usePathname,useSearchParamsand the segment hooks as possiblynull. The URL map’ssegments.tsxthen failed witherror TS18047: 'all' is possibly 'null'; handle thenullin shared components.
Source: useRouter: router object, useRouter: resetting state after navigation, Pages Router: Dynamic Routes
| Concern | App Router (app/) |
Pages Router (pages/) |
|---|---|---|
Folder or file |
|
|
Catch-all shapes |
|
the same names, as files: |
Reading the value on the server |
|
|
Reading the value in a component |
|
|
Value type |
|
|
Prerendering known values |
|
|
Unknown values |
|
|
A query parameter with the same name as a segment |
No collision: |
The route param overrides it in |
Encoded characters in a segment |
Page and layout |
Decoded |
Segments you do not want in the URL |
route groups |
(none: every file is a route; keep non-route code outside |
Private files |
private folders |
(none: only files under |
What Changed Since Next.js 13 and 14
If you learned dynamic routes from the Next.js 13/14 documentation or from a project created in those years, five things differ.
| Version | Change | What to do |
|---|---|---|
13.4 |
App Router stable. |
Use them for new routes. See the previous section for the Pages Router equivalents. |
13.3.0 |
|
Use it for params deep in the client tree. |
15 |
|
|
15.5 |
|
Replace hand-written |
16 |
Synchronous access is removed: |
Run |
16.3 |
|
Replace prop drilling of |
A route written for 13/14 looks like this, and is a type error in 16:
export default function Page({ params }: { params: { slug: string } }) {
return <h1>{params.slug}</h1>;
}
and the current form is:
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
return <h1>{slug}</h1>;
}
Where the Books Are Outdated
The two consulted books describe the Pages Router with Next.js 12 and 13.2, so everything about dynamic routes in them is Pages Router code. Nothing is wrong for that router, but the following has moved:
| Topic | In the books | Today (Next.js 16.3) |
|---|---|---|
Dynamic |
|
|
Prerendering the product pages |
|
|
Synchronous params |
|
A Promise since 15; the synchronous form is removed in 16. |
Slug into a query string |
The slug is interpolated into a CMS query. |
Validate the param and pass it as a query parameter, never by concatenation. |
Route organisation |
Every file under |
Route groups |
Router hooks |
|
|
Source for the book editions: Real-World Next.js (Packt) and Practical Next.js for E-Commerce (Springer)
Pitfalls and Errors
| Pitfall | Symptom | Fix |
|---|---|---|
Reading |
TypeScript error |
|
Assuming params are decoded |
In 16.3.8 page and layout params arrive percent-encoded: |
Call |
Awaiting |
Build error |
Pass the Promise to a child inside |
|
Build error |
Wrap the Client Component in |
Returning |
Build error |
Return at least one sample; a placeholder plus |
Exporting |
Build error |
Remove it and handle unknown values in the page with |
Forgetting that catch-all params are arrays in |
Routes not generated, or a type error. |
Return |
|
Build error |
Remove |
Two groups defining the same URL |
Build error |
Rename or move one route. |
Navigating between routes of different root layouts |
Full page reload, losing client state. |
Keep related routes under one root layout. |
|
|
Validate before the boundary where possible, and add a |
A Client Component page that needs samples under Cache Components |
Build fails with an unresolved |
A server |
Reading a root parameter in a Route Handler or Server Action |
Not supported (planned for Route Handlers). |
Read |
A kebab-case root segment ( |
|
Name the segment with a valid identifier ( |
Stale |
|
Delete the old root layout so |
Trusting a param as clean input |
Path traversal, query injection, or a 500 on bad input. |
Validate with a schema; encode when you put it into a URL; use parameterised queries. |
Returning numbers, or a string for a catch-all, from |
Build error |
Return strings ( |
Expecting |
|
Generate every level that must exist. |
An encoded |
HTTP 500, |
Generate slugs from |
A query parameter named like a segment (Pages Router) |
|
Rename the query parameter. |
Adding |
|
Handle |
Pages Router: reading |
|
Use the data functions, or wait for |