Concept
The beginner framing: dynamic route segments let you generate routes from data instead of hand-creating a folder for every possible value, one [slug] folder handles every blog post, instead of one folder per post.
The precise mental model: wrapping a folder name in square brackets, [slug], creates a dynamic segment, and its value is passed to page, layout, route, and generateMetadata as a params prop that is a Promise, not a plain object. You must await it (or use React's use() in a Client Component).
// app/blog/[slug]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // ⚠️ params is a Promise, must await
const post = await getPost(slug);
return <h1>{post.title}</h1>;
}| Route | Example URL | params |
|---|---|---|
app/blog/[slug]/page.js | /blog/a | { slug: 'a' } |
app/shop/[...slug]/page.js (catch-all) | /shop/a/b/c | { slug: ['a', 'b', 'c'] } |
app/shop/[[...slug]]/page.js (optional catch-all) |
Catch-all vs. optional catch-all
app/shop/[...slug]/page.js // matches /shop/a, /shop/a/b, etc., but NOT bare /shop
app/shop/[[...slug]]/page.js // matches ALL of the above, PLUS bare /shop itselfThe only difference is whether the segment-less route (/shop) is also matched, a plain catch-all requires at least one segment; the double-bracket optional form also matches zero.
generateStaticParams + Cache Components: two very different code paths
Whether a dynamic route needs a <Suspense> boundary around its param access depends entirely on whether you provide generateStaticParams:
// WITHOUT generateStaticParams, params is genuinely runtime data
import { Suspense } from "react";
export default function Page({ params }: PageProps<"/blog/[slug]">) {
return (
<Suspense fallback={<div>Loading...</div>}>
{params.then(({ slug }) => <Content slug={slug} />)}
</Suspense>
);
}// WITH generateStaticParams, sample params are validated + prerendered at build time
export async function generateStaticParams() {
return [{ slug: "1" }, { slug: "2" }, { slug: "3" }];
}
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params; // no Suspense needed for THESE sample values
return <Content slug={slug} />;
}Without generateStaticParams, every param value is genuinely unknown until request time, so Next.js treats param access as runtime data requiring a <Suspense> fallback. With it, the listed sample values get prerendered and validated at build time, but any param value not in that list is only validated on its first real request, and any conditional branch never exercised by the samples isn't validated until someone actually hits it.
Try It
Predict what happens before checking the solution.
export async function generateStaticParams() {
return [{ slug: "public-post" }];
}
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
if (slug.startsWith("private-")) {
return <PrivatePost slug={slug} />; // reads cookies(), no Suspense wrap
}
return <PublicPost slug={slug} />;
}A request comes in for /blog/private-secret, a slug never listed in generateStaticParams, whose branch reads cookies() directly. What happens?
Solution
This request errors. generateStaticParams only ever exercised the public-post branch at build time, the private-* branch, which calls the runtime-only cookies() API without a <Suspense> wrap, was never validated. At request time for an actual private-* slug, Next.js requires runtime API access to be wrapped in Suspense (under Cache Components) and this branch isn't, causing an error. The fix is to wrap PrivatePost in its own <Suspense fallback={...}> boundary, exactly as the runtime-data pattern requires.
Implement It Yourself
Build a minimal dynamic-segment matcher that mirrors the three bracket forms:
function matchSegment(pattern, urlSegments) {
if (pattern.startsWith("[[...") && pattern.endsWith("]]")) {
// optional catch-all: matches zero or more segments
return { slug: urlSegments.length ? urlSegments : undefined };
}
if (pattern.startsWith("[...") && pattern.endsWith("]")) {
// catch-all: requires at least one segment
return urlSegments.length ? { slug: urlSegments } : null;
}
if (pattern.startsWith("[") && pattern.
This captures the exact matching rule difference between the three bracket forms, the double-bracket optional form is the only one that successfully matches an empty segment list.
Under the Hood
params being a Promise (rather than a plain synchronous object, as it was in Next 14 and earlier) mirrors the same async-boundary reasoning from Server Components, a dynamic segment's value genuinely isn't known until a request arrives, so treating it as a promise (awaited, or read via use()) keeps the API honest about when that data actually becomes available, rather than pretending it's synchronously ready. The catch-all matching logic is a straightforward application of Data Structures's array concepts, a catch-all segment is really just "the rest of the path, as an array," identical to a rest parameter (...args) in a JavaScript function signature.
Common Mistakes
1. Accessing params synchronously, the old way
export default function Page({ params }: { params: { slug: string } }) {
return <h1>{params.slug}</h1>; // ❌ params is a Promise now, this is stale Next 14 code
}This is the single most common porting mistake from older Next.js knowledge. params (and searchParams) must be awaited (Server Components) or read via use() (Client Components), treating them as plain objects either fails outright or silently relies on deprecated backwards-compatibility behavior.
2. Confusing catch-all with optional catch-all
Using [...slug] when the bare, segment-less route should also match (or vice versa), remember: one set of brackets more ([[...slug]]) means the zero-segment case is also matched.
3. Assuming every param value is validated at build time just because generateStaticParams exists
Covered in Try It, only the sample values actually listed (and the conditional branches they exercise) are validated at build time. Anything else is only checked the first time a real request hits it.
Best Practices
- Always
await params/searchParams(oruse()in Client Components), never access them synchronously. - Use
PageProps<'/route'>,LayoutProps<'/route'>, orRouteContext<'/route'>to typeparamscorrectly for your exact route, instead of hand-writing the shape. - Provide
generateStaticParamsfor any dynamic route whose values are largely known ahead of time (blog posts, product IDs), but still test at least one "unlisted" value's code path before shipping, since it's genuinely unvalidated until a real request exercises it. - Validate narrow, known param sets at runtime (e.g., a segment with a fixed list of valid codes) using to reject anything outside that set, narrowing the type for the rest of the function.
Performance Tips
- Prerendering routes via
generateStaticParamsmoves the cost of rendering from every request to build time once, the single biggest lever for dynamic-route performance when the set of valid params is largely known in advance. fetchcalls insidegenerateStaticParamsare automatically deduplicated against identical calls elsewhere in the same build (layouts, pages, othergenerateStaticParamscalls), avoiding redundant network requests during the build itself.
