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/hellomatches,/blog/hello/commentsdoes not.[...path]matches one or more segments./docs/amatches,/docs/a/b/cmatches, but/docsalone does NOT match.[[...path]]matches zero or more segments. Same as catch-all, but also matches the base path (/shopwith no segments).- Params are delivered as a
Promisein Next.js 15+. You mustawait paramsin Server Components oruse(params)in Client Components. generateStaticParamspre-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.tsxproducesparams: { lang, slug }. - Unknown dynamic paths fall through to
not-found.tsxwhen no page matches, or when you callnotFound().
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./docswill 404 unless you haveapp/docs/page.tsxseparately. Use[[...path]]if you need the base. - Param values are always strings.
/product/42gives{ id: "42" }, not a number. Parse manually. generateStaticParamsruns at build time. If your data source is unavailable during build, the build fails. UsedynamicParams = true(the default) to allow on-demand rendering.- Order matters for overlapping routes. Static routes take priority over dynamic ones.
/blog/aboutmatchesapp/blog/about/page.tsxbeforeapp/blog/[slug]/page.tsx. - Params are now async. Destructuring
{ params: { slug } }directly in the function signature no longer works in Next.js 15+. Alwaysawait. dynamicParams = falsereturns 404 for unlisted params. Only paths returned bygenerateStaticParamswill work.
// Opt out of on-demand dynamic rendering
export const dynamicParams = false;Alternatives
| Approach | When 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 rewrites | Map custom URLs to existing dynamic routes |
searchParams instead of path params | Filtering 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.,/shopor/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();Related
- App Router Basics — file conventions overview
- Route Groups — organizing without URL segments
- Navigation — navigating to dynamic routes programmatically
- Loading & Error — loading states for dynamic pages