React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

dynamic-routesslugcatch-alloptional-catch-allparamsgenerateStaticParams

Dynamic Routes

Use bracket syntax to create routes that match dynamic URL segments — single params, catch-all segments, and optional catch-all patterns.

Recipe

Quick-reference recipe card — copy-paste ready.

app/
├── blog/[slug]/page.tsx          # /blog/hello-world     → { slug: "hello-world" }
├── docs/[...path]/page.tsx       # /docs/a/b/c           → { path: ["a", "b", "c"] }
└── shop/[[...categories]]/page.tsx  # /shop or /shop/a/b → { categories: ["a", "b"] } or {}
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>Post: {slug}</h1>;
}

When to reach for this: Any route where the URL contains a variable — product IDs, usernames, documentation paths, or locale prefixes.

Working Example

// app/blog/[slug]/page.tsx — Single dynamic segment
import { notFound } from "next/navigation";
 
interface Post {
  slug: string;
  title: string;
  content: string;
}
 
async function getPost(slug: string): Promise<Post | null> {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 },
  });
  if (!res.ok) return null;
  return res.json();
}
 
export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
 
  if (!post) notFound();
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
 
// Static generation for known slugs
export async function generateStaticParams() {
  const posts: Post[] = await fetch("https://api.example.com/posts").then((r) =>
    r.json()
  );
  return posts.map((post) => ({ slug: post.slug }));
}
// app/docs/[...path]/page.tsx — Catch-all route
export default async function DocsPage({
  params,
}: {
  params: Promise<{ path: string[] }>;
}) {
  const { path } = await params;
  // /docs/getting-started/install → path = ["getting-started", "install"]
  const fullPath = path.join("/");
 
  return (
    <div>
      <h1>Docs: {fullPath}</h1>
      <p>Segments: {path.length}</p>
    </div>
  );
}
// app/shop/[[...categories]]/page.tsx — Optional catch-all
export default async function ShopPage({
  params,
}: {
  params: Promise<{ categories?: string[] }>;
}) {
  const { categories } = await params;
 
  if (!categories || categories.length === 0) {
    return <h1>All Products</h1>;
  }
 
  return (
    <div>
      <h1>Shop: {categories.join(" > ")}</h1>
      <p>Filtering by {categories.length} categories</p>
    </div>
  );
}

Deep Dive

How It Works

  • [slug] matches a single segment. /blog/hello matches, /blog/hello/comments does not.
  • [...path] matches one or more segments. /docs/a matches, /docs/a/b/c matches, but /docs alone does NOT match.
  • [[...path]] matches zero or more segments. Same as catch-all, but also matches the base path (/shop with no segments).
  • Params are delivered as a Promise in Next.js 15+. You must await params in Server Components or use(params) in Client Components.
  • generateStaticParams pre-renders dynamic routes at build time. Return an array of param objects, and Next.js generates a static page for each.
  • Dynamic segments can be nested. app/[lang]/blog/[slug]/page.tsx produces params: { lang, slug }.
  • Unknown dynamic paths fall through to not-found.tsx when no page matches, or when you call notFound().

Variations

// Multiple dynamic segments
// app/[locale]/blog/[slug]/page.tsx
export default async function LocalizedPost({
  params,
}: {
  params: Promise<{ locale: string; slug: string }>;
}) {
  const { locale, slug } = await params;
  return <h1>{locale}: {slug}</h1>;
}
 
export async function generateStaticParams() {
  return [
    { locale: "en", slug: "hello" },
    { locale: "fr", slug: "bonjour" },
  ];
}
// generateStaticParams with parent params
// app/[category]/[product]/page.tsx
export async function generateStaticParams({
  params,
}: {
  params: { category: string };
}) {
  const products = await getProductsByCategory(params.category);
  return products.map((p) => ({ product: p.slug }));
}
// Dynamic route with generateMetadata
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post?.title ?? "Not Found",
    description: post?.content?.slice(0, 160),
  };
}

TypeScript Notes

// Param types by pattern
type SingleParam = { slug: string };                    // [slug]
type CatchAllParam = { path: string[] };                // [...path]
type OptionalCatchAll = { categories?: string[] };      // [[...categories]]
type MultiParam = { locale: string; slug: string };     // [locale]/[slug]
 
// All wrapped in Promise for Next.js 15+ pages
type PageProps = {
  params: Promise<SingleParam>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
 
// generateStaticParams return type
// Returns param objects WITHOUT Promise wrapper
type StaticParams = SingleParam[];

Gotchas

  • Catch-all [...path] does NOT match the base route. /docs will 404 unless you have app/docs/page.tsx separately. Use [[...path]] if you need the base.
  • Param values are always strings. /product/42 gives { id: "42" }, not a number. Parse manually.
  • generateStaticParams runs at build time. If your data source is unavailable during build, the build fails. Use dynamicParams = true (the default) to allow on-demand rendering.
  • Order matters for overlapping routes. Static routes take priority over dynamic ones. /blog/about matches app/blog/about/page.tsx before app/blog/[slug]/page.tsx.
  • Params are now async. Destructuring { params: { slug } } directly in the function signature no longer works in Next.js 15+. Always await.
  • dynamicParams = false returns 404 for unlisted params. Only paths returned by generateStaticParams will work.
// Opt out of on-demand dynamic rendering
export const dynamicParams = false;

Alternatives

ApproachWhen to Use
Static route (app/about/page.tsx)URL is fixed and known ahead of time
Route Groups (group)Organize without adding URL segments
Middleware rewritesMap custom URLs to existing dynamic routes
searchParams instead of path paramsFiltering or sorting that does not need unique URLs

FAQs

What is the difference between [slug], [...path], and [[...path]]?
  • [slug] matches exactly one URL segment (e.g., /blog/hello)
  • [...path] matches one or more segments (e.g., /docs/a/b/c) but not the base route
  • [[...path]] matches zero or more segments, including the base route (e.g., /shop or /shop/a/b)
Gotcha: Why does /docs return a 404 when using [...path]?

Catch-all [...path] requires at least one segment. /docs alone does not match. Either create a separate app/docs/page.tsx for the base route or switch to optional catch-all [[...path]].

How do you access params in a Server Component in Next.js 15+?
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

You must await params because they are now a Promise.

What does generateStaticParams do and when does it run?
  • It runs at build time to pre-render dynamic routes as static pages
  • Return an array of param objects, one per page to generate
  • Unknown paths still render on demand unless dynamicParams = false
Are param values always strings?

Yes. Even if the URL contains a number like /product/42, the param value is the string "42". You must parse it manually (e.g., Number(id) or parseInt(id)).

How do static routes and dynamic routes interact when paths overlap?

Static routes take priority. /blog/about matches app/blog/about/page.tsx before app/blog/[slug]/page.tsx.

What happens if you set dynamicParams = false?

Only paths returned by generateStaticParams will work. Any other dynamic path returns a 404.

export const dynamicParams = false;
How do you generate metadata for a dynamic route?
import type { Metadata } from "next";
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return { title: post?.title ?? "Not Found" };
}
What are the correct TypeScript types for each dynamic route pattern?
type SingleParam = { slug: string };
type CatchAllParam = { path: string[] };
type OptionalCatchAll = { categories?: string[] };
type MultiParam = { locale: string; slug: string };
 
// All wrapped in Promise for page props
type PageProps = {
  params: Promise<SingleParam>;
};
Gotcha: Can you destructure params directly in the function signature?

Not in Next.js 15+. The synchronous destructuring pattern { params: { slug } } no longer works because params is now a Promise. Always await params inside the function body.

How does generateStaticParams work with nested dynamic segments?
// app/[category]/[product]/page.tsx
export async function generateStaticParams({
  params,
}: {
  params: { category: string };
}) {
  const products = await getProductsByCategory(params.category);
  return products.map((p) => ({ product: p.slug }));
}

The parent segment's params are passed into the child's generateStaticParams.

How do you handle a missing resource in a dynamic route?

Call notFound() from next/navigation to trigger the nearest not-found.tsx.

import { notFound } from "next/navigation";
 
const post = await getPost(slug);
if (!post) notFound();