Skip to content

Dynamic Segments and Route Groups

Bracketed folders turn URL parts into params you read from a Promise, while parenthesized and underscored folders reshape your file tree without changing the URL.

A folder named [id] captures one URL part as a param. In current Next.js params is a Promise, so you await it. The same is true for searchParams.

app/blog/[id]/page.tsx
export default async function Post({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { id } = await params
const { sort } = await searchParams
return <article>Post {id} sorted by {sort}</article>
}

Use [...slug] to capture many segments as an array. Use [[...slug]] to make it optional so the parent route also matches with no extra segments.

app/
docs/
[...slug]/page.tsx // matches /docs/a and /docs/a/b/c
shop/
[[...slug]]/page.tsx // matches /shop AND /shop/a/b
app/docs/[...slug]/page.tsx
export default async function Docs({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
return <h1>{slug.join(' / ')}</h1>
}

The optional catch-all’s empty case is undefined, not an empty array

Section titled “The optional catch-all’s empty case is undefined, not an empty array”

When [[...slug]] matches the parent path with no extra segments at all, params.slug is undefined — not []. Built and requested for real:

app/shop/[[...slug]]/page.tsx
export default async function Shop({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
return <p>slug: {String(slug)}</p>
}
curl http://localhost:3000/shop
-> slug: undefined

Code that does slug.length or slug.map(...) without first checking slug?.length throws at runtime for exactly the route the optional catch-all was meant to also match — the un-nested base path.

generateStaticParams prerenders dynamic routes

Section titled “generateStaticParams prerenders dynamic routes”

Export generateStaticParams to tell Next.js which param values to prerender at build time. Each returned object becomes one statically generated page.

app/blog/[id]/page.tsx
export function generateStaticParams() {
return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

With cacheComponents enabled, generateStaticParams must return at least one param — an empty array is a build error.

dynamicParams controls the params generateStaticParams did NOT list — but it doesn’t exist under cacheComponents

Section titled “dynamicParams controls the params generateStaticParams did NOT list — but it doesn’t exist under cacheComponents”

dynamicParams (default true) decides what happens when a visitor hits a param value that generateStaticParams never returned: by default Next.js renders it dynamically on first request and can then cache the result; dynamicParams = false makes any unlisted param 404 instead.

// app/blog/[slug]/page.tsx — variant B
export const dynamicParams = false
export function generateStaticParams() {
return [{ slug: 'a' }, { slug: 'b' }]
}

This route segment config option is part of the pre-cacheComponents caching model, and it is explicitly incompatible with it. Turning cacheComponents: true on in next.config.ts and keeping a dynamicParams export is a real build error, not a silent no-op:

next build (cacheComponents: true, dynamicParams present)
-> Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.

Under cacheComponents, the equivalent control is structural rather than a config flag: params you listed in generateStaticParams get build-time validation and static HTML; params you didn’t list become genuine runtime data that must be wrapped in <Suspense> (see the caching module’s Cache Components section) — there is no dynamicParams = false switch to make an unlisted param 404 outright.

A folder in parentheses like (marketing) is a route group: it organizes files and can hold its own shared layout, but its name is stripped from the URL. A folder with a leading underscore like _components is a private folder that opts out of routing entirely.

app/
(marketing)/
layout.tsx // layout for marketing pages only
about/page.tsx // URL is /about, NOT /marketing/about
(shop)/
cart/page.tsx // URL is /cart
_components/ // private, never a route
Button.tsx

Route groups don’t share a layout with each other, and crossing between root layouts forces a full reload

Section titled “Route groups don’t share a layout with each other, and crossing between root layouts forces a full reload”

Each route group can define its own root layout — that’s the main reason to use them (splitting (marketing) and (shop) so each gets a completely different <html>/<body> and providers). Nothing links those two layouts, and a client-side navigation from one group’s route to the other’s cannot reuse any of the first group’s state, because the two trees don’t share an ancestor inside the app.

// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body data-group="marketing">{children}</body>
</html>
)
}
// app/(shop)/layout.tsx
export default function ShopLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body data-group="shop">{children}</body>
</html>
)
}

Proven in a real browser: load /about (the (marketing) group), set a marker on window, then click a <Link> to /cart (the (shop) group).

before clicking Link -> { group: "marketing", marker: "still-here", loadCount: 1 }
after clicking Link to /cart -> { group: "shop", marker: undefined, loadCount: 1 }

marker disappearing means the entire JavaScript execution context was thrown away and restarted — a full page reload — even though the navigation happened through <Link> and even though the Network tab still shows an _rsc fetch attempt first. Next.js tries the soft navigation, detects the response belongs to a different root layout, and falls back to a real document reload instead of trying to reconcile two incompatible trees.

graph TD
  A["[id] dynamic segment"] --> U1["changes URL: /blog/42"]
  B["[...slug] catch-all"] --> U2["changes URL: /docs/a/b"]
  C["(marketing) route group"] --> U3["no URL change"]
  D["_components private"] --> U4["not routable"]
  C -->|"different root layout than"| E["(shop) route group"]
  E -->|"Link between them"| F["full page reload, not soft nav"]
Folders that do vs do not change the URL, and where reloads happen

Treat brackets, parentheses, and underscores as three unrelated file-system tools that all happen to live in the same folder names. Brackets are the only ones that touch the URL — they capture data. Parentheses and underscores only reorganize files on disk; a route group additionally lets you give a whole subtree its own root layout, and the moment it does, that subtree becomes its own separate document as far as the browser is concerned — navigating into or out of it is never a soft navigation.

ConventionChanges the URL?Can define its own root layout?
[id] / [...slug] / [[...slug]]Yes — captures the valueNo
(group)NoYes — and doing so removes soft navigation to/from sibling groups with a different root layout
_privateNo — not routable at allNo
dynamicParams (pre-cacheComponents model)N/AControls 404 vs runtime-render for unlisted params; not usable at all once cacheComponents is on
  • “params.slug is [] when [[...slug]] matches with no extra segments.” Proven above: it’s undefined, and code that assumes an array crashes on exactly the base route the optional form exists to also match.
  • “dynamicParams = false still works once I turn on cacheComponents.” It’s a real build error, not a warning — the two models are mutually exclusive by design.
  • “Since it’s a <Link> click, it must be a soft navigation.” Not when the destination has a different root layout — proven with a window marker that gets wiped, meaning the whole page actually reloaded.

When a generated snippet touches dynamic segments or route groups, check for these before trusting it:

  • Does an optional catch-all handler call slug.length or slug.map(...) without a null check? The un-nested base route makes slug undefined, not [].
  • Is dynamicParams exported alongside a cacheComponents: true config? That’s a build error, not a valid combination.
  • Does the code assume every route group under app/ shares one root layout? Each group with its own top-level layout.tsx gets its own, unrelated <html>/<body>.
  • Is a “fast client-side navigation” claimed between two route groups that each define <html>/<body>? That crossing is always a full reload, proven above.
  • Are two different route groups defining a page at the same resulting URL (e.g. (marketing)/about/page.tsx and (shop)/about/page.tsx)? Next.js treats that as a conflicting path and errors.

🐛 Spot the bug

app/shop/[[...slug]]/page.tsx
export default async function Shop({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const crumbs = slug.map((s) => s.toUpperCase())
return <p>{crumbs.join(' / ')}</p>
}
Show the answer

This assumes slug is always an array. Proven above: for the un-nested /shop route that the optional catch-all is specifically designed to also match, params.slug is undefined — calling .map on it throws a TypeError at request time for exactly the “empty” case the [[...]] form exists to handle. The fix is const crumbs = (slug ?? []).map(...).

In current Next.js, how do you read the id from a dynamic segment?
Requested at exactly /shop (no extra segments), what happens?
const { slug } = await params // route is app/shop/[[...slug]]/page.tsx
return <p>{slug.length}</p>
What does generateStaticParams do?
What happens when this builds?
// next.config.ts has cacheComponents: true
export const dynamicParams = false
export function generateStaticParams() { return [{ slug: 'a' }] }
What is the effect of a (marketing) route group on the URL?
Two route groups, (marketing) and (shop), each define their own root layout.tsx with <html>/<body>. Clicking a <Link> from a (marketing) page to a (shop) page does what, proven in a real browser?