Dynamic Segments and Route Groups
The idea in one sentence
Section titled “The idea in one sentence”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.
Dynamic segments read from a Promise
Section titled “Dynamic segments read from a Promise”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.
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>}Catch-all and optional catch-all
Section titled “Catch-all and optional catch-all”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/bexport 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:
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: undefinedCode 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.
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 Bexport 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.
Route groups and private folders
Section titled “Route groups and private folders”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.tsxRoute 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.tsxexport default function MarketingLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="en"> <body data-group="marketing">{children}</body> </html> )}// app/(shop)/layout.tsxexport 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"]
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Convention | Changes the URL? | Can define its own root layout? |
|---|---|---|
[id] / [...slug] / [[...slug]] | Yes — captures the value | No |
(group) | No | Yes — and doing so removes soft navigation to/from sibling groups with a different root layout |
_private | No — not routable at all | No |
dynamicParams (pre-cacheComponents model) | N/A | Controls 404 vs runtime-render for unlisted params; not usable at all once cacheComponents is on |
Common Misconceptions
Section titled “Common Misconceptions”- “
params.slugis[]when[[...slug]]matches with no extra segments.” Proven above: it’sundefined, and code that assumes an array crashes on exactly the base route the optional form exists to also match. - “
dynamicParams = falsestill works once I turn oncacheComponents.” 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 awindowmarker that gets wiped, meaning the whole page actually reloaded.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches dynamic segments or route groups, check for these before trusting it:
- Does an optional catch-all handler call
slug.lengthorslug.map(...)without a null check? The un-nested base route makesslugundefined, not[]. - Is
dynamicParamsexported alongside acacheComponents: trueconfig? 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-levellayout.tsxgets 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.tsxand(shop)/about/page.tsx)? Next.js treats that as a conflicting path and errors.
🐛 Spot the bug
export default async function Shop({params,}: {params: Promise<{ slug?: string[] }>}) {const { slug } = await paramsconst 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(...).