Type something to search...
Dynamic Routes in Next.js: Mastering [slug], Catch-All, and Optional Catch-All Segments

Dynamic Routes in Next.js: Mastering [slug], Catch-All, and Optional Catch-All Segments

Most real routes aren't known ahead of time. A blog has a page per post, a store has a page per product, and a docs site has pages nested three or four levels deep. You don't create a folder for each of those. You create one folder with square brackets in its name, and Next.js fills in the value from the URL.

The App Router has three flavors of dynamic segment: the regular [slug], the catch-all [...slug], and the optional catch-all [[...slug]]. They look similar, but they match different URLs and give you differently shaped params. Picking the wrong one is a common source of 404s and confusing type errors.

This post covers all three, how to read and type params in Next.js 16, how to prerender dynamic routes with generateStaticParams, how to validate params, and the matching rules that decide which route wins when several could match.

The Three Kinds of Dynamic Segment

Here's the quick reference before we go into each one:

FolderMatchesDoesn't matchparams shape
app/blog/[slug]/blog/hello/blog, /blog/a/b{ slug: string }
app/docs/[...slug]/docs/a, /docs/a/b/c/docs{ slug: string[] }
app/shop/[[...slug]]/shop, /shop/a, /shop/a/b(matches all of them){ slug?: string[] }

The name inside the brackets is up to you. [slug], [id], and [productId] all work the same way; the name only decides the key in params.

Single Dynamic Segments: [slug]

A folder named [slug] matches exactly one URL segment at that position.

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";

export default async function PostPage({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

There are two things to notice.

First, params is a promise. Since Next.js 15 you have to await it, and in Next.js 16 the old synchronous access has been removed completely. If you see an error about accessing params.slug directly, that's the cause.

Second, the type comes from PageProps<"/blog/[slug]">. This is a global helper that Next.js generates from your app directory when you run next dev, next build, or next typegen. You don't import it. It knows that this route has a slug param and types params as Promise<{ slug: string }>. If you rename the folder, TypeScript flags every page that still uses the old route literal.

If you prefer to write the type by hand, this is equivalent:

// app/blog/[slug]/page.tsx
type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

Multiple Dynamic Segments

You can nest dynamic folders. Each one adds a key to params:

// app/shop/[category]/[product]/page.tsx
export default async function ProductPage({
  params,
}: PageProps<"/shop/[category]/[product]">) {
  const { category, product } = await params;

  return (
    <h1>
      {product} in {category}
    </h1>
  );
}

A request to /shop/shoes/trail-runner gives you { category: "shoes", product: "trail-runner" }. Layouts receive the params from the root down to their own level, so app/shop/[category]/layout.tsx sees category but not product.

Catch-All Segments: [...slug]

Adding three dots makes a segment capture everything after it, one or more segments deep. This is what you want for documentation sites, file browsers, and CMS-driven pages where the nesting depth varies.

// app/docs/[...slug]/page.tsx
import { notFound } from "next/navigation";
import { getDocByPath } from "@/lib/docs";

export default async function DocPage({
  params,
}: PageProps<"/docs/[...slug]">) {
  const { slug } = await params;
  // /docs/guides/routing/basics -> ["guides", "routing", "basics"]
  const doc = await getDocByPath(slug.join("/"));

  if (!doc) notFound();

  return (
    <article>
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.html }} />
    </article>
  );
}

slug is always an array of strings here, with at least one element. Joining it with / gives you a path you can look up in your content source.

A catch-all does not match the parent path itself. /docs won't hit app/docs/[...slug]/page.tsx. If you need a docs index, add app/docs/page.tsx alongside it, or use an optional catch-all.

Building Breadcrumbs from a Catch-All

The array shape makes breadcrumbs simple:

// app/docs/[...slug]/breadcrumbs.tsx
import Link from "next/link";

export function Breadcrumbs({ slug }: { slug: string[] }) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex gap-2 text-sm">
        <li>
          <Link href="/docs">Docs</Link>
        </li>
        {slug.map((part, index) => {
          const href = `/docs/${slug.slice(0, index + 1).join("/")}`;
          const isLast = index === slug.length - 1;
          return (
            <li key={href}>
              {isLast ? (
                <span aria-current="page">{part}</span>
              ) : (
                <Link href={href}>{part}</Link>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

Each crumb's href is the slug sliced up to that position. Render <Breadcrumbs slug={slug} /> from the page after awaiting params.

Optional Catch-All Segments: [[...slug]]

Double brackets make the catch-all optional, so it also matches the parent path with no extra segments. When nothing follows, slug is undefined.

// app/shop/[[...filters]]/page.tsx
import { getProducts } from "@/lib/products";

export default async function ShopPage({
  params,
}: PageProps<"/shop/[[...filters]]">) {
  const { filters = [] } = await params;
  const [category, subcategory] = filters;

  const products = await getProducts({ category, subcategory });

  return (
    <>
      <h1>{category ?? "All products"}</h1>
      <ul>
        {products.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </>
  );
}

One file now handles /shop, /shop/shoes, and /shop/shoes/running. Defaulting filters to an empty array during destructuring removes the undefined case so the rest of the component doesn't need optional chaining.

Use an optional catch-all when the index page and the nested pages share the same layout and data logic. If the index page looks completely different, a separate page.tsx plus a regular catch-all is clearer.

Typing Params Without the Helper

If you're not using the generated helpers, here's how each segment type maps to TypeScript:

Routeparams type
app/blog/[slug]/page.tsxPromise<{ slug: string }>
app/docs/[...slug]/page.tsxPromise<{ slug: string[] }>
app/shop/[[...slug]]/page.tsxPromise<{ slug?: string[] }>
app/[locale]/[id]/page.tsxPromise<{ locale: string; id: string }>

Params are always strings (or arrays of strings). A route like app/orders/[id] gives you "42", not 42. Convert and validate before using it.

Validating Params

Users can type anything into the address bar. Treat params as untrusted input:

// app/orders/[id]/page.tsx
import { notFound } from "next/navigation";
import { getOrder } from "@/lib/orders";

export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
  const { id } = await params;
  const orderId = Number(id);

  if (!Number.isInteger(orderId) || orderId <= 0) notFound();

  const order = await getOrder(orderId);
  if (!order) notFound();

  return <h1>Order #{order.id}</h1>;
}

notFound() throws, renders the nearest not-found.tsx, and returns a 404 status. Calling it for malformed input as well as missing records keeps bad URLs from reaching your database layer.

For params with a fixed set of valid values, a TypeScript assertion function narrows the type for everything after it:

// app/[locale]/page.tsx
import { notFound } from "next/navigation";

const locales = ["en", "de", "fr"] as const;
type Locale = (typeof locales)[number];

function assertLocale(value: string): asserts value is Locale {
  if (!locales.includes(value as Locale)) notFound();
}

export default async function HomePage({ params }: PageProps<"/[locale]">) {
  const { locale } = await params;
  assertLocale(locale);
  // locale is now typed as "en" | "de" | "fr"
  return <h1>Locale: {locale}</h1>;
}

Prerendering with generateStaticParams

Without extra configuration, a dynamic route renders when it's requested. If you know the possible values at build time, export generateStaticParams to prerender them:

// app/blog/[slug]/page.tsx
import { getAllPosts } from "@/lib/posts";

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

The return value is an array of params objects. The shape depends on the segment type:

// app/docs/[...slug]/page.tsx
export function generateStaticParams() {
  return [
    { slug: ["getting-started"] },
    { slug: ["guides", "routing"] },
    { slug: ["guides", "routing", "dynamic-routes"] },
  ];
}
// app/shop/[[...filters]]/page.tsx
export function generateStaticParams() {
  return [
    { filters: [] }, // /shop
    { filters: ["shoes"] }, // /shop/shoes
    { filters: ["shoes", "running"] }, // /shop/shoes/running
  ];
}

For catch-all routes, each value is an array. For the optional catch-all, an empty array generates the bare /shop path.

Nested Dynamic Segments

With app/shop/[category]/[product], you can generate both params from the page:

// app/shop/[category]/[product]/page.tsx
import { getAllProducts } from "@/lib/products";

export async function generateStaticParams() {
  const products = await getAllProducts();
  return products.map((p) => ({
    category: p.categorySlug,
    product: p.slug,
  }));
}

Or you can split it: the layout at [category] returns the categories, and the page's generateStaticParams receives each parent value and returns the products for it:

// app/shop/[category]/[product]/page.tsx
import { getProductsInCategory } from "@/lib/products";

export async function generateStaticParams({
  params,
}: {
  params: { category: string };
}) {
  const products = await getProductsInCategory(params.category);
  return products.map((p) => ({ product: p.slug }));
}

Note that the params argument to generateStaticParams is a plain object, not a promise, and only contains the parent segments.

Controlling Unknown Params with dynamicParams

By default, a param that wasn't returned by generateStaticParams is rendered on first request and then cached. To return a 404 for anything you didn't list, set dynamicParams to false:

// app/blog/[slug]/page.tsx
export const dynamicParams = false;

This is a good fit for content that only changes when you redeploy, like a Markdown blog. This very site uses that approach. For content that changes between deploys, leave the default so new slugs work without a rebuild.

With Cache Components

If you enable cacheComponents in next.config.ts, two rules change:

  • dynamicParams isn't available. Params not covered by generateStaticParams are rendered on demand and saved after the first successful request.
  • generateStaticParams must return at least one entry. Next.js uses those samples to validate that the route doesn't access request-time data outside a Suspense boundary. An empty array is a build error.

If you don't export generateStaticParams at all, params become runtime data under Cache Components, and you need to read them inside a Suspense boundary (or provide a loading.tsx) so Next.js can prerender a static shell around them.

Reading Params in Client Components

Pages and layouts get params as a prop. Deeper Client Components can read the current route's params with useParams:

// app/shop/[category]/category-tabs.tsx
"use client";

import Link from "next/link";
import { useParams } from "next/navigation";

const categories = ["shoes", "jackets", "bags"];

export function CategoryTabs() {
  const { category } = useParams<{ category: string }>();

  return (
    <nav className="flex gap-4">
      {categories.map((c) => (
        <Link
          key={c}
          href={`/shop/${c}`}
          className={c === category ? "font-bold" : undefined}
        >
          {c}
        </Link>
      ))}
    </nav>
  );
}

useParams returns the params for the whole current URL, so a component in a layout can read params that belong to a child page. For a Client Component page that receives params as a prop, use React's use(params) to unwrap the promise, since Client Components can't be async.

Dynamic Route Handlers

Route Handlers use the same folder conventions. The params arrive in the second argument:

// app/api/files/[...path]/route.ts
export async function GET(
  _request: Request,
  { params }: RouteContext<"/api/files/[...path]">,
) {
  const { path } = await params;
  return Response.json({ requested: path.join("/") });
}

RouteContext is the Route Handler counterpart to PageProps, generated the same way.

How Next.js Picks a Route

When more than one route could match a URL, Next.js prefers the most specific one:

  1. Static segments win over dynamic ones. With app/blog/new/page.tsx and app/blog/[slug]/page.tsx, a request to /blog/new goes to the static page. Every other slug goes to [slug].
  2. Single dynamic segments win over catch-alls. With app/docs/[slug] and app/docs/[...slug], /docs/intro goes to [slug], while /docs/guides/intro goes to the catch-all.
  3. Catch-alls are the last resort at their level.

A few combinations aren't allowed at all, and the build will tell you:

  • Two sibling dynamic folders with different names, like app/blog/[id] and app/blog/[slug]. They match the same URLs, so Next.js can't choose. Pick one name.
  • An optional catch-all next to a page for the same path, like app/shop/page.tsx and app/shop/[[...slug]]/page.tsx. Both would handle /shop.
  • Any two route groups or folders that resolve to the same final URL.

Linking to Dynamic Routes

Build the href with a template literal or a URL object:

// app/blog/post-list.tsx
import Link from "next/link";

type Post = { slug: string; title: string };

export function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <Link href={`/blog/${encodeURIComponent(post.slug)}`}>
            {post.title}
          </Link>
        </li>
      ))}
    </ul>
  );
}

Encoding the slug matters if your slugs can contain spaces, non-ASCII characters, or reserved characters. If you control slug generation, a simpler fix is to restrict slugs to lowercase letters, digits, and hyphens when you create them.

Common Mistakes

Forgetting to await params. params.slug on the promise is undefined. Always const { slug } = await params.

Using [...slug] when you need the index too. A regular catch-all doesn't match the parent path. Use [[...slug]] or add a separate page.tsx.

Treating params as numbers. They're strings. Convert and validate.

Returning the wrong shape from generateStaticParams. Catch-all params must be arrays: { slug: ["a", "b"] }, not { slug: "a/b" }.

Fetching the same record twice. If both generateMetadata and the page load the post, wrap the loader in React's cache (or use fetch, which is deduplicated automatically) so it runs once per request.

Conclusion

Dynamic segments come down to three shapes: [slug] for exactly one segment, [...slug] for one or more, and [[...slug]] for zero or more. Await params, let PageProps type them for you, validate them like any user input, and call notFound() early when they don't make sense.

When the set of values is known, generateStaticParams turns dynamic routes into prerendered pages, and dynamicParams decides what happens to everything else. For more on how those prerendered pages are refreshed over time, see the post on incremental static regeneration.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading