deep-dive2024-08-01·11 min·283/348

Generating Dynamic Routes in Next.js App Router

Complete guide to creating dynamic routes with generateStaticParams, catch-all routes, and optional segments in App Router

Generating Dynamic Routes in Next.js App Router

Introduction

Dynamic routes are the backbone of most web applications. Blog posts, product pages, user profiles, and documentation all require URL segments that vary. The App Router introduces a fundamentally different approach to dynamic routing compared to Pages Router, using generateStaticParams instead of getStaticPaths.

After building a documentation site with hundreds of pages generated from a CMS, I learned every nuance of App Router dynamic routing. This post covers the complete set of patterns.

Environment

  • OS: Windows 11
  • Node.js: v20.10.0
  • Next.js: 14.0.4
  • Content Source: Markdown files and CMS API

Problem

The key challenges with App Router dynamic routes are:

Error: Missing required parameter: slug
Error: generateStaticParams() returned an invalid value.
Each route must have a unique set of params.
Error: Catch-all route [...] must provide a default export.

When using [...slug] with generateStaticParams, the returned params format differs from Pages Router:

// Pages Router (OLD)
// getStaticPaths returns: { params: { slug: ['a', 'b', 'c'] } }

// App Router (NEW)
// generateStaticParams returns: { slug: ['a', 'b', 'c'] }
// Not wrapped in params object!

Solution

Pattern 1: Basic dynamic segment

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

interface Post {
  slug: string
  title: string
  content: string
}

async function getPost(slug: string): Promise {
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  if (!res.ok) return null
  return res.json()
}

export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts')
    .then((res) => res.json())

  return posts.map((post: Post) => ({
    slug: post.slug,
  }))
}

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)
  if (!post) return { title: 'Post Not Found' }

  return {
    title: post.title,
    description: post.content.substring(0, 160),
  }
}

export default async function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()
  }

  return (
    

{post.title}

{post.content}
) }

Pattern 2: Catch-all routes

// app/docs/[...slug]/page.tsx
import { notFound } from 'next/navigation'

interface DocPage {
  slug: string[]
  title: string
  content: string
  lastUpdated: string
}

async function getDocPage(slug: string[]): Promise {
  const path = slug.join('/')
  const res = await fetch(`https://api.example.com/docs/${path}`)
  if (!res.ok) return null
  return res.json()
}

export async function generateStaticParams() {
  const docs = await fetch('https://api.example.com/docs/all')
    .then((res) => res.json())

  return docs.map((doc: DocPage) => ({
    slug: doc.slug,
  }))
}

export default async function DocPage({
  params,
}: {
  params: { slug: string[] }
}) {
  const page = await getDocPage(params.slug)

  if (!page) {
    notFound()
  }

  return (
    

{page.title}

{page.content}

Last updated: {page.lastUpdated}

) }

Pattern 3: Optional catch-all routes

// app/shop/[[...category]]/page.tsx
import { Suspense } from 'react'
import { ProductGrid } from '@/components/ProductGrid'

interface ShopPageProps {
  params: { category?: string[] }
}

async function getProducts(category?: string[]) {
  const url = category
    ? `https://api.example.com/products?category=${category.join('/')}`
    : 'https://api.example.com/products'

  const res = await fetch(url)
  return res.json()
}

export async function generateStaticParams() {
  const categories = await fetch('https://api.example.com/categories')
    .then((res) => res.json())

  // Generate params for each category level
  const params = [{ category: undefined }] // Root shop page

  for (const cat of categories) {
    params.push({ category: [cat.slug] })
    for (const subcat of cat.children || []) {
      params.push({ category: [cat.slug, subcat.slug] })
    }
  }

  return params
}

export default async function ShopPage({ params }: ShopPageProps) {
  const products = await getProducts(params.category)
  const categoryTitle = params.category
    ? params.category.map((c) => c.replace(/-/g, ' ')).join(' > ')
    : 'All Products'

  return (
    

{categoryTitle}

Loading products...
}>
) }

Pattern 4: Route groups for shared layouts

// app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    
Marketing Header
{children}
Marketing Footer
) } // app/(app)/layout.tsx export default function AppLayout({ children, }: { children: React.ReactNode }) { return (
{children}
) }

Lessons Learned

  1. generateStaticParams returns params directly, not wrapped. Unlike Pages Router's getStaticPaths, the App Router version returns { slug: 'value' } not { params: { slug: 'value' } }.

  2. Use notFound() instead of throwing errors. When a dynamic segment does not match any generated params, calling notFound() renders the closest not-found.tsx file.

  3. Catch-all params are arrays. [...slug] produces params.slug as string[], not a single string. Join them to reconstruct the path.

  4. Optional catch-all params can be undefined. [[...slug]] means params.category might be undefined for the base route. Always handle this case.

  5. Combine with route groups to share layouts across different sections of your site without affecting the URL structure.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.