Dynamic & Catch-all Segments in Depth

Master multi-param routes, optional catch-alls for docs sites, and the async params contract in Next 15.

The basics of [id] are easy; the depth is in combining segments and knowing exactly what shape params arrives in.

Multiple dynamic segments

A route can have several dynamic parts. app/shop/[category]/[product]/page.tsx gives you { category, product }. Each is a plain string.

Catch-all vs optional catch-all

  • [...slug] — matches one or more segments; the base path (/docs) does not match and 404s.
  • [[...slug]] — the optional form; the base path matches too, and there slug is simply undefined.

That difference is why docs and wiki systems almost always use the optional form: one [[...slug]] route renders the index and every nested page.

The Next 15 async contract

params and searchParams are Promises now. You await them. This is not cosmetic — it lets Next start rendering the static shell before per-request params are resolved, which is what makes Partial Prerendering possible. Type searchParams honestly: every value is string | string[] | undefined, because ?tag=a&tag=b yields an array.

Example

Example · typescript
// app/shop/[category]/[[...filters]]/page.tsx
// Matches /shop/shoes, /shop/shoes/red, /shop/shoes/red/size-10
type Params = { category: string; filters?: string[] };
type Search = { [key: string]: string | string[] | undefined };

export default async function Products({
  params,
  searchParams,
}: {
  params: Promise<Params>;
  searchParams: Promise<Search>;
}) {
  const { category, filters = [] } = await params;
  const { sort } = await searchParams;

  const products = await queryProducts({ category, filters, sort });
  return (
    <main>
      <h1>{category}</h1>
      <p>Filters: {filters.join(' / ') || 'none'}</p>
      <ProductGrid items={products} />
    </main>
  );
}

When to use it

  • A docs site uses [[...slug]] so both /docs and /docs/getting-started/install resolve to the same page component.
  • An API route uses multiple dynamic params like [team]/[project] to scope data access in a team workspace app.
  • A developer awaits the params Promise in Next.js 15 rather than destructuring synchronously to avoid runtime warnings.

More examples

Multi-param route

Multiple dynamic segments nest naturally; awaiting params gives all captured values as typed properties.

Example · ts
// app/[org]/[repo]/page.tsx
type Props = { params: Promise<{ org: string; repo: string }> };

export default async function RepoPage({ params }: Props) {
  const { org, repo } = await params;
  return <h1>{org}/{repo}</h1>;
}

Optional catch-all for docs

Optional catch-all handles /docs (undefined slug) and /docs/a/b (array slug) in one component.

Example · ts
// app/docs/[[...slug]]/page.tsx
type Props = { params: Promise<{ slug?: string[] }> };

export default async function DocsPage({ params }: Props) {
  const { slug } = await params;
  const section = slug ? slug.join('/') : 'index';
  const content = await getDocContent(section);
  return <article dangerouslySetInnerHTML={{ __html: content }} />;
}

generateStaticParams for catch-all

Returning an entry with undefined slug includes /docs itself in the static generation alongside all child paths.

Example · ts
// app/docs/[[...slug]]/page.tsx
export async function generateStaticParams() {
  const pages = await getAllDocPages();
  return [
    { slug: undefined },           // /docs
    ...pages.map(p => ({ slug: p.path.split('/') })),
  ];
}

Discussion

  • Be the first to comment on this lesson.
Dynamic & Catch-all Segments in Depth — Next.js | SoundsCode