Next.js Dynamic Routes — Params, Slugs, and Static Generation
Advertisement
Introduction
Dynamic routes allow a single file to handle many different URLs. In the Next.js App Router, folders wrapped in square brackets like [slug] or [id] create dynamic segments that capture variable parts of the URL. Combined with generateStaticParams, you can pre-render thousands of dynamic pages at build time for maximum performance.
Why This Matters
Every blog post, product page, and user profile is a dynamic route. Getting this pattern right determines whether your site scales gracefully. Pre-rendering with generateStaticParams turns dynamic pages into static HTML — served from CDN edge nodes in under 50ms, compared to 200–800ms for server-rendered pages.
The App Router also typed params as a Promise in Next.js 15, requiring you to await them in Server Components. Understanding this change prevents a common TypeScript error when upgrading.
Basic Dynamic Route
Create a file at app/blog/[slug]/page.tsx:
// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params // Next.js 15: params is a Promise
const post = await fetch(`https://cms.example.com/posts/${slug}`, {
next: { revalidate: 3600 },
}).then((r) => r.json())
return (
<article>
<h1>{post.title}</h1>
<p className="text-gray-500">{new Date(post.publishedAt).toLocaleDateString()}</p>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
)
}Multiple Dynamic Segments
Nest multiple dynamic folders for multi-level routes:
app/
└── shop/
└── [category]/
└── [product]/
└── page.tsx → /shop/electronics/iphone-15// app/shop/[category]/[product]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ category: string; product: string }>
}) {
const { category, product } = await params
const data = await fetch(`https://api.example.com/shop/${category}/${product}`).then((r) =>
r.json()
)
return (
<div>
<nav>
<a href={`/shop/${category}`}>{category}</a> / {data.name}
</nav>
<h1>{data.name}</h1>
<p>${data.price}</p>
</div>
)
}Catch-All Routes
[...slug] matches any number of URL segments:
// app/docs/[...slug]/page.tsx
// Matches: /docs/intro, /docs/api/auth, /docs/guide/setup/step-1
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
const path = slug.join('/') // e.g., 'api/auth' or 'guide/setup/step-1'
const content = await fetchDoc(path)
return (
<div>
<nav>
{slug.map((segment, i) => (
<span key={i}>
{i > 0 && ' / '}
<a href={`/docs/${slug.slice(0, i + 1).join('/')}`}>{segment}</a>
</span>
))}
</nav>
<main>{content.body}</main>
</div>
)
}Optional Catch-All Routes
[[...slug]] also matches the root segment (no slug):
app/docs/[[...slug]]/page.tsx
// Matches: /docs, /docs/intro, /docs/api/authexport default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const path = slug?.join('/') ?? 'index'
return <div>Viewing: {path}</div>
}generateStaticParams for Static Pre-rendering
Pre-generate all known dynamic routes at build time. Next.js generates static HTML for each returned parameter:
// app/blog/[slug]/page.tsx
export async function generateStaticParams(): Promise<{ slug: string }[]> {
const posts = await fetch('https://cms.example.com/posts', {
cache: 'force-cache',
}).then((r) => r.json())
return posts.map((post: { slug: string }) => ({ slug: post.slug }))
}Pages not returned by generateStaticParams are rendered on-demand and cached. Set dynamicParams = false to return 404 for unknown slugs:
export const dynamicParams = false // 404 for slugs not in generateStaticParamsnotFound for Missing Content
Return 404 for content that does not exist:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await fetchPost(slug)
if (!post) {
notFound() // Renders app/blog/[slug]/not-found.tsx
}
return <article>{post.content}</article>
}// app/blog/[slug]/not-found.tsx
export default function PostNotFound() {
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold">Post Not Found</h1>
<p className="mt-4 text-gray-600">The post you are looking for does not exist.</p>
<a href="/blog" className="mt-8 inline-block text-blue-600">Browse all posts</a>
</div>
)
}Dynamic Metadata for Each Route
Generate unique metadata per dynamic page:
// 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 fetchPost(slug)
if (!post) return { title: 'Not Found' }
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
}
}searchParams for Query Strings
Access URL query parameters in pages:
// app/products/page.tsx
// Handles: /products?category=shoes&sort=price&page=2
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; sort?: string; page?: string }>
}) {
const { category, sort, page } = await searchParams
const products = await fetchProducts({
category,
sort,
page: parseInt(page ?? '1'),
})
return (
<div>
<h1>Products: {category ?? 'All'}</h1>
<ul>
{products.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
</div>
)
}Common Mistakes
- Accessing
params.slugsynchronously in Next.js 15 — params is now a Promise, must be awaited - Not calling
notFound()for missing content — returns 200 with empty page instead of 404 - Forgetting
generateStaticParamsfor content-heavy sites — all pages render on demand - Using
paramsin Client Components — pass them as props from a Server Component parent - Not handling the case where
searchParamsvalues are arrays (e.g.,?tag=a&tag=b)
Best Practices
- Always
await paramsin Next.js 15 — both in page components andgenerateMetadata - Use
generateStaticParamsfor any route with predictable URLs (blog posts, products, docs) - Call
notFound()immediately when fetched content is null — return 404 early - Use
revalidateor tags on content fetches inside dynamic routes for ISR - Pass
searchParamsto filtering/pagination functions server-side — avoids client-side JavaScript - Use
dynamicParams = true(default) to allow new slugs to be rendered on demand after deployment
Key Takeaways
- Dynamic segments use square brackets:
[slug],[id],[...slug](catch-all),[[...slug]](optional catch-all) - In Next.js 15,
paramsis a Promise — you mustawait paramsbefore accessing properties generateStaticParamspre-renders dynamic routes at build time for CDN-speed performancedynamicParams = falsereturns 404 for any slug not returned bygenerateStaticParamsnotFound()fromnext/navigationtriggers the nearestnot-found.tsxfilesearchParams(also a Promise in Next.js 15) contains URL query string values as strings or string arraysgenerateMetadatareceives the sameparamsas the page and shouldawaitthem identically- Multiple dynamic segments can be nested:
/shop/[category]/[product]/page.tsx
Advertisement