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: slugError: 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.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}
)
}
// app/(app)/layout.tsx
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}Lessons Learned
generateStaticParamsreturns params directly, not wrapped. Unlike Pages Router'sgetStaticPaths, the App Router version returns{ slug: 'value' }not{ params: { slug: 'value' } }.Use
notFound()instead of throwing errors. When a dynamic segment does not match any generated params, callingnotFound()renders the closestnot-found.tsxfile.Catch-all params are arrays.
[...slug]producesparams.slugasstring[], not a single string. Join them to reconstruct the path.Optional catch-all params can be undefined.
[[...slug]]meansparams.categorymight beundefinedfor the base route. Always handle this case.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.