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:

The slice of the Bookshelf used on this page
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)
A diagram titled Folder, URL and params: how the Bookshelf routes map. A three-column table with the columns Folder, URL and params. The row for app/[locale]/(shop)/page.tsx serves /en with params locale en. The row for (shop)/books/[id]/page.tsx serves /en/books/3 with locale en and id 3. The row for (shop)/shelf catch-all path page serves /en/shelf/fiction/sci-fi with path set to the array fiction, sci-fi. The row for (marketing)/docs optional catch-all slug page serves /en/docs and /en/docs/a/b with slug undefined or the array a, b plus locale. The row for (marketing)/about/page.tsx serves /en/about. A highlighted last row for the private folder _components/book-card.tsx says it has no URL and is not routable. A note below says the route groups vanish from the URL, a single bracket segment is one string, a catch-all is a string array of one or more segments and an optional catch-all is a string array or undefined.

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.

The Bookshelf URL map
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:

Table 1. URL, matched file and params value (observed)
URL Matched file (under app/[locale]/) params Result

/en/books/1

(shop)/books/[id]/page.tsx

{ locale: 'en', id: '1' }

200, prerendered

/en/books/01

(shop)/books/[id]/page.tsx

{ locale: 'en', id: '01' }

308 to /en/books/1 (the page’s redirect)

/en/books/abc, /en/books/1e3

(shop)/books/[id]/page.tsx

{ locale: 'en', id: 'abc' }

404 from parseId

/fr/books/1

(shop)/books/[id]/page.tsx

{ locale: 'fr', id: '1' }

404 from the root layout’s locale guard

/en/my-reviews

(account)/my-reviews/page.tsx

{ locale: 'en' }

200, account layout

/en/checkout

(checkout)/checkout/page.tsx

{ locale: 'en' }

200, no shop navigation

/en/shelf/fiction/sci-fi

(shop)/shelf/[...slug]/page.tsx

{ locale: 'en', slug: ['fiction', 'sci-fi'] }

200

/en/shelf

none (a catch-all needs a segment)

-

404

/en/docs

(marketing)/docs/[[...slug]]/page.tsx

{ locale: 'en' }, slug undefined

200

/en/(shop)/books/1, /en/Books/1

none: a group is never a segment, and static segments are case-sensitive

-

404

/en/books/1/

(redirect before matching)

-

308 to /en/books/1 (default trailingSlash)

next build lists the same map, with the groups nowhere in it:

next build route table of the URL map (Next.js 16.3.8, previous model)
$ 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:

flowchart TD url["Request path, e.g. /en/books/new"] --> strip["Set aside (group) and _private folders:
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

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.

Table 2. The three shapes and what each one matches
Folder Matches Example URL and params params type

app/blog/[slug]/page.tsx

exactly one segment

/blog/a gives { slug: 'a' }

{ slug: string }

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

one or more segments (a catch-all)

/shop/a/b/c gives { slug: ['a', 'b', 'c'] }; /shop does not match

{ slug: string[] }

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

zero or more segments (an optional catch-all)

/shop gives { slug: undefined }; /shop/a/b gives { slug: ['a', 'b'] }

{ slug?: string[] }

app/[categoryId]/[itemId]/page.tsx

two dynamic segments

/books/42 gives { categoryId: 'books', itemId: '42' }

{ categoryId: string, itemId: string }

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:

app/[locale]/(shop)/books/[id]/page.tsx
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.

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:

Static segments win over dynamic ones (production server, port 3303)
$ 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.

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:

app/[locale]/(shop)/shelf catch-all path page
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>
  );
}
Results (production server)
$ curl -s localhost:3303/en/shelf/fiction/sci-fi | grep -o 'data-segments="[0-9]*">Shelf:.*</p>'
data-segments="2">Shelf: <!-- -->fiction &gt; 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).

Optional Catch-all: [[…​slug]]

For the Bookshelf documentation, one file serves /en/docs and every deeper page:

app/[locale]/(marketing)/docs optional catch-all slug 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>;
}
Results (production server)
$ 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:

Build error when page.tsx sits next to [[...slug]]
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.

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.

flowchart TD q{"Where do you read the param?"} q -- "async Server 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"]
Table 3. Which tool reads params where
Where How Notes

Async Server Component (page, layout, route, generateMetadata, image files)

const { id } = await params

The default. params carries every dynamic segment from the root down to this file.

Synchronous Server Component

Pass params (the Promise) as a prop and await it in a child; or call use(params)

React’s use API reads a Promise during render. Under Cache Components, keep the read inside <Suspense>.

Client Component page or layout

const { id } = use(params)

The Promise arrives as a prop and use unwraps it. A Client Component cannot be async.

Any Client Component in the tree

useParams() from next/navigation

Returns an object of the active params (empty object when the route has none). No prop drilling.

Root parameters anywhere on the server

await locale() from next/root-params

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:

generateMetadata receives the same params Promise as the page
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" };
}
Result
$ 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:

app/api/books/[id]/route.ts
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);
}
Result (production server)
$ 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

Source: page.js, route.js

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:

app/[locale]/(shop)/books/[id]/reviews/page.tsx (previous model)
"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>;
}
Result (production server)
$ 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:

app/[locale]/(shop)/books/[id]/crumbs.tsx
"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>
  );
}
Result (production server): the hook is also rendered on the server for the first HTML
$ 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:

app/[locale]/(marketing)/docs optional catch-all slug page (Cache Components)
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):

next build with cacheComponents: true and the Suspense boundary removed
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:

Table 4. params types by route shape
Route params type

app/blog/[slug]/page.tsx

{ slug: string }

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

{ slug: string[] }

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

{ slug?: string[] }

app/[categoryId]/[itemId]/page.tsx

{ categoryId: string, itemId: string }

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:

A page with two deliberate mistakes
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>;
}
next build fails in the TypeScript step
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:

next build with numeric ids in generateStaticParams
$ 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:

Excerpt of the generated .next/types/routes.d.ts for the URL map
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.

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):

lib/params.ts
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 /${locale}/book/${id} (a typo for books) fails the TypeScript step of next build with TS2322, while /${locale}/books/${id} passes. Prefer template literals for links to dynamic routes; a concatenated string is not checked and needs an 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:

app/[locale]/(shop)/shelf catch-all slug page
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>
  );
}
A page and a Route Handler receive different forms (production server, Next.js 16.3.8)
$ 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.)

Table 5. How each router hands over an encoded segment (observed on 16.3.8)
Segment App Router page or layout App Router Route Handler Pages Router params/query

sci%20fi

'sci%20fi'

'sci fi'

'sci fi'

caf%C3%A9 or raw café

'caf%C3%A9'

'café'

'café'

a%2Fb

'a%2Fb' (one segment)

'a/b' (one segment)

'a/b' (one segment)

a+b

'a%2Bb'

'a+b'

'a+b'

100%25

HTTP 500, failed to decode param

'100%'

'100%'

%E0%A4%A (malformed)

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 answers 500 for %25 before your code runs. Generate slugs from [a-z0-9-].

  • Validate after decoding. curl --path-as-is /en/shelf/a/../b arrives as ['b'] (dot segments are resolved), but ..%2F..%2Fetc is one segment that decodes to ../../etc. Never join a decoded param into a file path.

  • Return decoded values from generateStaticParams. The café shelf, generated from { slug: ['café'] }, was a cache HIT for both /en/shelf/caf%C3%A9 and 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).

Table 6. The return shape of generateStaticParams
Route Returns

/product/[id]

{ id: string }[]

/products/[category]/[product]

{ category: string, product: string }[]

/products/[...slug]

{ slug: string[] }[]

Note that catch-all params are arrays, one array per URL. The Bookshelf page below prerenders the first two books:

app/[locale]/(shop)/books/[id]/page.tsx (previous model)
// 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:

next build route table, excerpt (previous model; ● means prerendered with generateStaticParams)
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:

Top down: the parent generates [author], the child [title]
// 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 by generateStaticParams.

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:

app/[locale]/(shop)/books/[id]/page.tsx: only the ids from generateStaticParams exist
// Previous model only: false turns every id outside generateStaticParams into a 404.
// It is not allowed when cacheComponents is on.
export const dynamicParams = false;
Results (production server, build with 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 []):

Return [] from generateStaticParams: render on first request, then serve from cache (previous model)
$ 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:

  • generateStaticParams must 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 read cookies(), headers() or searchParams incorrectly.

  • dynamicParams is not available: exporting it fails the build, whatever its value.

  • Values returned by generateStaticParams are 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-') reading cookies()) is validated on the first request instead, so wrap such branches in <Suspense>.

Exporting dynamicParams with cacheComponents: true
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:

app/[locale]/(shop)/books/[id]/page.tsx (Cache Components)
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>
  );
}
Results (production server, cacheComponents: true)
$ 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:

Cache Components: a server page with samples hands the Promise to a 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.

flowchart TD req["Request for /en/books/N"] --> known{"Is N returned by
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:

Samples for the two catch-all routes of the URL map
// 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;:

Catch-all routes with dynamicParams = false (previous model)
$ 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:

Table 7. Which catch-all URLs exist, by caching model (observed)
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 (MISS, then HIT)

Bare path of an optional catch-all (/en/docs)

Prerendered if { slug: [] } was returned

404 unless { slug: [] } was returned

Prerendered if { slug: [] } is a sample

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:

Two groups, two layouts, one URL space
// 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>;
}
Each page gets the layout of its own group (production server)
$ 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.

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:

Counter values after each client-side navigation (one shared root layout)
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 id remounts 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.

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:

Route groups per team, with matching CODEOWNERS entries
# .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:

app/[locale]/_components/segments.tsx, rendered by the root layout
"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)} />;
}
Results (production server; HTML entities decoded)
$ 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/:

Two root layouts, one per route group
// 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>
  );
}
Each route gets the body of its own root layout (production server)
$ 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.tsx and (marketing)/about/page.tsx the build fails:

    Two routes that resolve to /about
    Error: 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.

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:

The same navigation in the two variants (production server, Chromium)
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.

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/:

app/[locale]/_components/book-card.tsx, imported by the catalogue page
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:

app/[locale]/layout.tsx: the root layout reads the locale with a getter
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>
  );
}
The book page reads the same value without any prop drilling
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>
Result (production server)
$ 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).

Table 8. What next/root-params can and cannot do (Next.js 16.3)
Situation Behaviour

Server Components

Supported anywhere: layouts, pages, shared utilities. No server-only import needed: using it in a Client Component fails the build.

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 [post-slug] is not supported and errors.

generateStaticParams

Required only with Cache Components, where every root parameter needs at least one value. A nested generateStaticParams can call the getter instead of reading params.

'use cache'

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.

unstable_cache

Calling a getter inside it throws at runtime. Use 'use cache'.

The earlier experimental unstable_rootParams was removed in 16.0; next/root-params shipped in 16.3 as its replacement.

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:

pages/books/[id].tsx: getStaticPaths, getStaticProps and useRouter().query
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>
  );
}
next build route table (Pages Router)
Route (pages)          Revalidate  Expire
┌ ○ /
├ ○ /404
├   /books/[id]                1m      1y
│ ├ ● /books/1
│ └ ● /books/2
├ ƒ /docs/[[...slug]]
└ ○ /shelf/[...path]
Results (production server)
$ 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 getServerSideProps and getStaticProps, params values are string | string[] | undefined (ParsedUrlQuery), even for a plain [id]; the App Router types a plain segment as string. Code that declares a string[] | null prop must normalise, as the experiment’s getServerSideProps does with typeof slug === "string" ? [slug] : (slug ?? null).

  • Timing. On a page that is statically optimised (no data function), router.query is an empty object during prerendering, and is filled after hydration. The experiment’s pages/shelf/[...path].tsx rendered ready=false path=- in the HTML. Read router.query only when router.isReady is 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:

Results (production server; HTML entities decoded)
$ 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=2 gives query.id === '1', and the ?id=2 is lost. In the App Router params and searchParams are separate props and never collide.

  • Values are decoded: 'café', '100%', and b%2Fc as one segment 'b/c', where an App Router page receives 'caf%C3%A9' (see Special Characters in Segments: Encoding and Decoding). Check before porting a decodeURIComponent call between the routers.

  • State is not reset when only the param changes. /legacy/1 to /legacy/2 renders the same component, so React keeps its state; the documented fixes are a key on Component in pages/_app.tsx or an effect keyed on router.query.id. In the App Router a layout under [id] remounts per id on its own.

  • The next/navigation hooks become nullable when both routers are present. With a pages/ directory, Next.js adds /// <reference types="next/navigation-types/compat/navigation" /> to next-env.d.ts, which types useParams, usePathname, useSearchParams and the segment hooks as possibly null. The URL map’s segments.tsx then failed with error TS18047: 'all' is possibly 'null'; handle the null in shared components.

Table 9. App Router and Pages Router dynamic routes side by side
Concern App Router (app/) Pages Router (pages/)

Folder or file

app/books/[id]/page.tsx (folder)

pages/books/[id].tsx (file) or pages/books/[id]/index.tsx

Catch-all shapes

[...slug], [[...slug]]

the same names, as files: [...slug].tsx, [[...slug]].tsx

Reading the value on the server

await params (Promise prop)

params in getStaticProps, getServerSideProps; context.params

Reading the value in a component

use(params), useParams(), next/root-params

useRouter().query (and useParams(), null before the router is ready)

Value type

string, string[], undefined by shape

string | string[] | undefined

Prerendering known values

generateStaticParams

getStaticPaths returning paths

Unknown values

dynamicParams true (default) or false; with Cache Components, static shell plus streaming

fallback: false (404), true (shell, router.isFallback), 'blocking' (wait, then cache)

A query parameter with the same name as a segment

No collision: params and searchParams are separate props

The route param overrides it in query

Encoded characters in a segment

Page and layout params stay percent-encoded; Route Handler params are decoded

Decoded

Segments you do not want in the URL

route groups (name)

(none: every file is a route; keep non-route code outside pages/)

Private files

private folders _name

(none: only files under pages/ matter)

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.

Table 10. What changed in dynamic routes
Version Change What to do

13.4

App Router stable. generateStaticParams replaces getStaticPaths; dynamicParams replaces fallback.

Use them for new routes. See the previous section for the Pages Router equivalents.

13.3.0

useParams introduced for Client Components in next/navigation.

Use it for params deep in the client tree.

15

params becomes a Promise. Synchronous access still works temporarily, for compatibility; codemod next-async-request-api migrates.

await params or use(params).

15.5

next typegen and the global PageProps, LayoutProps and RouteContext helpers.

Replace hand-written { params: { slug: string } } with PageProps<'/blog/[slug]'>.

16

Synchronous access is removed: params.id on the Promise is a type error. cacheComponents turns params without samples into runtime data requiring <Suspense>; dynamicParams is not allowed with it. generateImageMetadata still receives sync params, but the image function now receives params and id as Promises.

Run npx @next/codemod@canary next-async-request-api . and fix the leftovers.

16.3

next/root-params ships (the earlier unstable_rootParams was removed in 16.0). ISR with App Shell for unknown params under Cache Components plus partialPrefetching.

Replace prop drilling of locale with the getter.

A route written for 13/14 looks like this, and is a type error in 16:

Next.js 13/14 style (sync params): removed in Next.js 16
export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>;
}

and the current form is:

Next.js 15/16 style
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:

Table 11. Dynamic-route topics where the books are behind
Topic In the books Today (Next.js 16.3)

Dynamic [slug] routes

pages/products/[slug].js (Real-World Next.js, Practical Next.js for E-Commerce), read with useRouter().query or getStaticProps({ params }).

app/products/[slug]/page.tsx with await params; still valid under pages/.

Prerendering the product pages

getStaticPaths with fallback: 'blocking' plus getStaticProps (the e-commerce book).

generateStaticParams with dynamicParams; getStaticPaths still works in the Pages Router.

Synchronous params

params is a plain object in the App Router of Next.js 13.

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 pages/ is a route; non-route code lives elsewhere.

Route groups (name) and private folders _name, and multiple root layouts.

Router hooks

next/router (useRouter).

next/navigation (useParams, usePathname, useRouter) in the App Router.

Pitfalls and Errors

Table 12. Dynamic-route pitfalls, with the symptom and the fix
Pitfall Symptom Fix

Reading params synchronously (params.id)

TypeScript error Property 'id' does not exist on type 'Promise<...>'; synchronous access is removed in 16.

const { id } = await params, use(params) or the codemod.

Assuming params are decoded

In 16.3.8 page and layout params arrive percent-encoded: /en/raw/caf%C3%A9 gives "caf%C3%A9" and a%2Fb stays as a%2Fb. Route Handler and Pages Router params are decoded.

Call decodeURIComponent once in pages, then validate (see Special Characters in Segments: Encoding and Decoding).

Awaiting params outside <Suspense> with Cache Components

Build error blocking-prerender-dynamic.

Pass the Promise to a child inside <Suspense>, or supply generateStaticParams samples.

useParams() for an uncovered param outside <Suspense> with Cache Components

Build error blocking-prerender-client-hook.

Wrap the Client Component in <Suspense>.

Returning [] from generateStaticParams with Cache Components

Build error empty-generate-static-params.

Return at least one sample; a placeholder plus notFound() works but weakens validation.

Exporting dynamicParams with Cache Components

Build error Route segment config "dynamicParams" is not compatible with nextConfig.cacheComponents.

Remove it and handle unknown values in the page with notFound().

Forgetting that catch-all params are arrays in generateStaticParams

Routes not generated, or a type error.

Return { path: ['fiction', 'sci-fi'] }, not a string.

docs/page.tsx next to docs/[[...slug]]/page.tsx

Build error same specificity as an optional catch-all route.

Remove docs/page.tsx and branch on slug === undefined.

Two groups defining the same URL

Build error two parallel pages that resolve to the same path.

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.

notFound() inside a streamed boundary (Cache Components)

/en/books/99 returns HTTP 200 with the shell.

Validate before the boundary where possible, and add a noindex meta tag.

A Client Component page that needs samples under Cache Components

Build fails with an unresolved [id] placeholder even though a layout had samples (observed in 16.3.8).

A server page.tsx exports generateStaticParams and passes params to the Client Component.

Reading a root parameter in a Route Handler or Server Action

Not supported (planned for Route Handlers).

Read params from RouteContext; pass the value to the action as an argument.

A kebab-case root segment ([post-slug])

next/root-params errors in dev and build.

Name the segment with a valid identifier ([postSlug]).

Stale app/layout.tsx next to a [locale] root layout

next/root-params has no locale export; build error Export locale doesn’t exist.

Delete the old root layout so [locale] is the root.

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 generateStaticParams

Build error A required parameter (id) was not provided as a string received number; TypeScript does not catch it.

Return strings (String(book.id)) and arrays of strings for catch-alls.

Expecting dynamicParams = false to allow parents or children of a generated catch-all path

/en/shelf/fiction and /en/shelf/fiction/sci-fi/x are 404.

Generate every level that must exist.

An encoded % (%25) or a malformed escape in a page segment

HTTP 500, Error: failed to decode param in the server log.

Generate slugs from [a-z0-9-].

A query parameter named like a segment (Pages Router)

/legacy/1?id=2 gives query.id === '1'.

Rename the query parameter.

Adding pages/ to an App Router project

next/navigation hooks become nullable (TS18047).

Handle null in shared components.

Pages Router: reading router.query on first render

undefined or empty values in the static HTML.

Use the data functions, or wait for router.isReady.