Next.js Dynamic Routes — Params, Slugs, and Static Generation

Sanjeev SharmaSanjeev Sharma
6 min read

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/auth
export 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 generateStaticParams

notFound 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.slug synchronously 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 generateStaticParams for content-heavy sites — all pages render on demand
  • Using params in Client Components — pass them as props from a Server Component parent
  • Not handling the case where searchParams values are arrays (e.g., ?tag=a&tag=b)

Best Practices

  • Always await params in Next.js 15 — both in page components and generateMetadata
  • Use generateStaticParams for any route with predictable URLs (blog posts, products, docs)
  • Call notFound() immediately when fetched content is null — return 404 early
  • Use revalidate or tags on content fetches inside dynamic routes for ISR
  • Pass searchParams to 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, params is a Promise — you must await params before accessing properties
  • generateStaticParams pre-renders dynamic routes at build time for CDN-speed performance
  • dynamicParams = false returns 404 for any slug not returned by generateStaticParams
  • notFound() from next/navigation triggers the nearest not-found.tsx file
  • searchParams (also a Promise in Next.js 15) contains URL query string values as strings or string arrays
  • generateMetadata receives the same params as the page and should await them identically
  • Multiple dynamic segments can be nested: /shop/[category]/[product]/page.tsx

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro