🎯 Chapter 5: Dynamic Routes & Params
One file that serves a million URLs, why
paramsis now a Promise, and how to pre-render dynamic pages at build time.
📖 Plain English Explanation
You have 10,000 blog posts. You are not going to create 10,000 folders.
Instead you create one folder with a bracketed name — app/blog/[slug]/page.tsx — and Next.js matches every URL of the shape /blog/anything to it, handing you the actual value of anything as a parameter.
That's the whole concept. The details worth learning are:
- Three bracket syntaxes, for matching one segment, many segments, or optionally-zero segments.
paramsis a Promise in Next.js 16 — this breaks nearly every tutorial written before late 2025.generateStaticParamslets you pre-build the popular pages at build time so they're static HTML instead of rendered per request.
🔤 The three bracket syntaxes
[slug] — one segment
app/blog/[slug]/page.tsx
| URL | params |
|---|---|
/blog/hello-world | { slug: 'hello-world' } |
/blog/2026-review | { slug: '2026-review' } |
/blog/a/b | ❌ no match (two segments) |
/blog | ❌ no match (zero segments) |
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
return <h1>Post: {slug}</h1>
}
// app/blog/[slug]/page.js
export default async function Page(props) {
const { slug } = await props.params
return <h1>Post: {slug}</h1>
}
Multiple dynamic segments nest naturally:
app/shop/[category]/[productId]/page.tsx
// app/shop/[category]/[productId]/page.tsx
export default async function Page(props: PageProps<'/shop/[category]/[productId]'>) {
const { category, productId } = await props.params
return <h1>{category} / {productId}</h1>
}
// app/shop/[category]/[productId]/page.js
export default async function Page(props) {
const { category, productId } = await props.params
return <h1>{category} / {productId}</h1>
}
/shop/shoes/nike-air-max → { category: 'shoes', productId: 'nike-air-max' }
[...slug] — catch-all, one or more segments
app/docs/[...slug]/page.tsx
| URL | params |
|---|---|
/docs/intro | { slug: ['intro'] } |
/docs/api/auth/login | { slug: ['api', 'auth', 'login'] } |
/docs | ❌ no match |
Note that slug is now an array.
// app/docs/[...slug]/page.tsx
export default async function Page(props: PageProps<'/docs/[...slug]'>) {
const { slug } = await props.params // string[]
const path = slug.join('/') // "api/auth/login"
return (
<article>
<nav>{slug.map((part, i) => <span key={i}> / {part}</span>)}</nav>
<Doc path={path} />
</article>
)
}
// app/docs/[...slug]/page.js
export default async function Page(props) {
const { slug } = await props.params
const path = slug.join('/')
return (
<article>
<nav>{slug.map((part, i) => <span key={i}> / {part}</span>)}</nav>
<Doc path={path} />
</article>
)
}
Perfect for documentation sites, CMS-driven pages, and file browsers where depth is unknown.
[[...slug]] — optional catch-all, zero or more segments
app/shop/[[...filters]]/page.tsx
| URL | params |
|---|---|
/shop | { filters: undefined } |
/shop/shoes | { filters: ['shoes'] } |
/shop/shoes/nike/red | { filters: ['shoes', 'nike', 'red'] } |
The extra brackets make the segment optional, so one file handles both the index and every nested variation.
// app/shop/[[...filters]]/page.tsx
export default async function Page(props: PageProps<'/shop/[[...filters]]'>) {
const { filters } = await props.params
const active = filters ?? [] // undefined at /shop
return (
<div>
<h1>{active.length ? `Filtered: ${active.join(' → ')}` : 'All products'}</h1>
<ProductGrid filters={active} />
</div>
)
}
// app/shop/[[...filters]]/page.js
export default async function Page(props) {
const { filters } = await props.params
const active = filters ?? []
return (
<div>
<h1>{active.length ? `Filtered: ${active.join(' → ')}` : 'All products'}</h1>
<ProductGrid filters={active} />
</div>
)
}
Always guard against undefined — that's the case that separates [[...x]] from [...x].
Which to pick
Known, fixed depth? → [slug] (or several)
Unknown depth, always ≥ 1? → [...slug]
Unknown depth, index too? → [[...slug]]
⏳ params is a Promise now
⚠️ Changed in Next.js 16
This is the breaking change you will hit most often.
params,searchParams,cookies(),headers(), anddraftMode()became async in Next.js 15 with a temporary synchronous fallback. In Next.js 16 the fallback is gone. Synchronous access throws.tsx// ❌ Next.js 14 — throws in 16 export default function Page({ params }: { params: { slug: string } }) { return <h1>{params.slug}</h1> } // ❌ Next.js 15 with the compat shim — also gone export default function Page({ params }) { return <h1>{params.slug}</h1> // warned in 15, throws in 16 } // ✅ Next.js 16 export default async function Page(props: PageProps<'/blog/[slug]'>) { const { slug } = await props.params return <h1>{slug}</h1> }Codemod for an existing codebase:
bashnpx @next/codemod@canary next-async-request-api .
Why did this change?
Because it unlocks streaming. When params is a Promise, Next.js can start rendering and sending the static parts of your page before it knows the route parameters, then fill them in. A synchronous params forces everything to wait. The same reasoning applies to cookies() and headers(): making them async lets the framework prerender the parts of a page that don't depend on the request.
The consequences
Every page and layout that reads params becomes async:
// ✅
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
}
In Client Components, use() unwraps the promise:
// app/blog/[slug]/Comments.tsx
'use client'
import { use } from 'react'
export function Comments({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
return <div>Comments for {slug}</div>
}
// app/blog/[slug]/Comments.js
'use client'
import { use } from 'react'
export function Comments({ params }) {
const { slug } = use(params)
return <div>Comments for {slug}</div>
}
Though in practice, useParams() is simpler for Client Components:
'use client'
import { useParams } from 'next/navigation'
export function Comments() {
const { slug } = useParams<{ slug: string }>()
return <div>Comments for {slug}</div>
}
Parallel awaits when you need both:
// app/shop/[category]/page.tsx
export default async function Page(props: PageProps<'/shop/[category]'>) {
const [{ category }, { sort }] = await Promise.all([
props.params,
props.searchParams,
])
return <ProductGrid category={category} sort={sort} />
}
🏷️ The typed props helpers
Instead of hand-writing { params: Promise<{ slug: string }> }, use the globals Next.js generates:
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params // slug: string ✅
const { page } = await props.searchParams // page: string | string[] | undefined
return <h1>{slug}</h1>
}
// app/blog/[slug]/layout.tsx
export default async function Layout(props: LayoutProps<'/blog/[slug]'>) {
const { slug } = await props.params
return <section data-post={slug}>{props.children}</section>
}
// app/api/posts/[id]/route.ts
export async function GET(request: Request, context: RouteContext<'/api/posts/[id]'>) {
const { id } = await context.params
return Response.json({ id })
}
No import needed — they're global. The route string is validated against your real folder structure, so a typo is a compile error. next dev and next build generate them automatically; run npx next typegen manually if you added routes with the dev server stopped.
🏗️ generateStaticParams — pre-render at build time
By default, a dynamic route renders on demand for each request. generateStaticParams tells Next.js "here are the values I know about — build them as static HTML now."
// app/blog/[slug]/page.tsx
import { getAllPosts, getPost } from '@/lib/posts'
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
return <article>{post.body}</article>
}
// app/blog/[slug]/page.js
import { getAllPosts, getPost } from '@/lib/posts'
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function Page(props) {
const { slug } = await props.params
const post = await getPost(slug)
return <article>{post.body}</article>
}
At build time Next.js calls generateStaticParams, gets [{ slug: 'hello' }, { slug: 'world' }], and generates /blog/hello and /blog/world as static files. Users get HTML from a CDN with no server work at all.
The returned values must be strings, even for numeric IDs:
export async function generateStaticParams() {
const products = await getProducts()
return products.map((p) => ({ id: String(p.id) })) // ← String() matters
}
Catch-all routes
Return arrays:
// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
return [
{ slug: ['intro'] },
{ slug: ['api', 'auth'] },
{ slug: ['api', 'auth', 'login'] },
]
}
Multiple dynamic segments
Return every combination:
// app/shop/[category]/[product]/page.tsx
export async function generateStaticParams() {
const products = await getAllProducts()
return products.map((p) => ({
category: p.category,
product: p.slug,
}))
}
Or generate them at each level — a child generateStaticParams receives the parent's params:
// app/shop/[category]/layout.tsx
export async function generateStaticParams() {
const categories = await getCategories()
return categories.map((c) => ({ category: c.slug }))
}
// app/shop/[category]/[product]/page.tsx
export async function generateStaticParams({ category }: { category: string }) {
const products = await getProductsIn(category)
return products.map((p) => ({ product: p.slug }))
}
dynamicParams — what about URLs you didn't list?
// app/blog/[slug]/page.tsx
export const dynamicParams = true // default
| Value | Behavior for an un-generated param |
|---|---|
true (default) | Render on demand, then cache. New posts work without a rebuild. |
false | Return 404. Only the listed params exist. |
Use false when the set is genuinely closed — a fixed list of countries, plan tiers, or locales:
// app/pricing/[plan]/page.tsx
export const dynamicParams = false
export function generateStaticParams() {
return [{ plan: 'free' }, { plan: 'pro' }, { plan: 'enterprise' }]
}
Now /pricing/anything-else is a clean 404 instead of a server render that fails.
⚠️ Not compatible with Cache Components
dynamicParamsis a legacy route segment config. If you havecacheComponents: truein yournext.config.ts, exporting it is a build error:Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.Under Cache Components there's nothing to configure — an unlisted param is served the App Shell instantly and the concrete page is filled in behind it and cached, which is the
dynamicParams: truebehaviour by default. To 404 an unlisted param, check for the record and callnotFound():tsx// app/pricing/[plan]/page.tsx — the Cache Components way import { notFound } from 'next/navigation' const PLANS = ['free', 'pro', 'enterprise'] as const export function generateStaticParams() { return PLANS.map((plan) => ({ plan })) } export default async function Page(props: PageProps<'/pricing/[plan]'>) { const { plan } = await props.params if (!PLANS.includes(plan as (typeof PLANS)[number])) notFound() return <PricingTable plan={plan} /> }Chapter 16 covers the App Shell in full.
A practical pattern: build the popular ones
You don't have to generate everything. Pre-build the top 100 posts, let the long tail render on demand:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const popular = await getTopPosts(100)
return popular.map((post) => ({ slug: post.slug }))
}
// dynamicParams defaults to true — post 101 renders on request and is then cached
Fast builds, fast pages, no trade-off.
🔎 searchParams
Query strings, also a Promise:
// app/search/page.tsx
export default async function Page(props: PageProps<'/search'>) {
const { q, page } = await props.searchParams
const query = typeof q === 'string' ? q : ''
const pageNum = Number(page ?? 1)
const results = await search(query, pageNum)
return <ResultList results={results} />
}
// app/search/page.js
export default async function Page(props) {
const { q, page } = await props.searchParams
const query = typeof q === 'string' ? q : ''
const pageNum = Number(page ?? 1)
const results = await search(query, pageNum)
return <ResultList results={results} />
}
Three things to know:
1. Values are string | string[] | undefined. Repeated keys give you an array:
/search?tag=a&tag=b → { tag: ['a', 'b'] }
/search?tag=a → { tag: 'a' }
/search → { tag: undefined }
Normalize before using:
const tags = Array.isArray(raw) ? raw : raw ? [raw] : []
2. Only page receives it. Layouts, route.ts, and generateStaticParams do not. Layouts don't re-render on query changes, so the value would be stale.
3. Reading it makes the route dynamic. The query string isn't known at build time, so a page reading searchParams cannot be fully static. With Cache Components enabled you can still prerender the shell and stream the dynamic part — Chapter 16 covers that.
4. Never trust it. It's user input straight from the URL bar:
// ❌ SQL injection / crash waiting to happen
const results = await db.query(`SELECT * FROM posts WHERE title = '${q}'`)
// ✅ validate first
import { z } from 'zod'
const schema = z.object({
q: z.string().max(100).optional(),
page: z.coerce.number().int().positive().max(1000).default(1),
})
export default async function Page(props: PageProps<'/search'>) {
const parsed = schema.safeParse(await props.searchParams)
if (!parsed.success) return <p>Invalid search.</p>
const { q, page } = parsed.data
// ...
}
🚫 Handling missing content
A valid-looking URL doesn't mean the content exists:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) {
notFound() // renders the nearest not-found.tsx, sends a 404 status
}
return <article>{post.body}</article>
}
// app/blog/[slug]/page.js
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export default async function Page(props) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) {
notFound()
}
return <article>{post.body}</article>
}
// app/blog/[slug]/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Post not found</h2>
<Link href="/blog">Back to the blog</Link>
</div>
)
}
Like redirect(), notFound() throws — don't call it inside a try block.
🌍 next/root-params
For apps with a dynamic root segment — internationalization or multi-tenancy — reading params in deeply nested components means threading it through every layer. next/root-params gives you direct access.
app/
└── [locale]/
├── layout.tsx
└── dashboard/
└── page.tsx
// lib/i18n.ts
import { locale } from 'next/root-params'
export async function t(key: string) {
const current = await locale() // 'en', 'fr', …
const dict = await import(`@/messages/${current}.json`)
return dict[key]
}
Each root dynamic segment becomes a named export. Any Server Component below can call it, no prop drilling.
⚠️ Changed in Next.js 16
unstable_rootParamswas removed. Usenext/root-paramsinstead.
⚠️ Common Pitfalls
1. Synchronous params
The number-one error when copying from older tutorials:
Error: Route "/blog/[slug]" used `params.slug`.
`params` should be awaited before using its properties.
Fix: const { slug } = await props.params, and make the function async.
2. Forgetting String() in generateStaticParams
// ❌ silently generates nothing
return products.map((p) => ({ id: p.id })) // id is a number
// ✅
return products.map((p) => ({ id: String(p.id) }))
Params are always strings. Numbers are ignored without an error, which makes this hard to spot.
3. Mismatched key names
Folder: app/blog/[slug]/page.tsx
Code: return posts.map((p) => ({ id: p.slug })) // ❌ key must be "slug"
The object key must match the folder name exactly.
4. Treating [...slug] as a string
const { slug } = await props.params
return <h1>{slug.toUpperCase()}</h1> // ❌ slug is string[]
Fix: slug.join('/') or index into it.
5. Not handling undefined in optional catch-alls
const { filters } = await props.params
filters.map(...) // ❌ crashes at /shop
// ✅
const active = filters ?? []
6. Reading searchParams in a layout
It isn't passed. Layouts persist across query-string changes, so the value would be stale by design. Fix: read it in the page, or use useSearchParams() in a Client Component.
7. Trusting params from the URL
params is user input too. /blog/../../etc/passwd gets URL-decoded before it reaches you.
// ❌
const content = await fs.readFile(`./content/${slug}.md`)
// ✅
if (!/^[a-z0-9-]+$/.test(slug)) notFound()
const content = await fs.readFile(`./content/${slug}.md`)
🎯 When & Why to Use
[slug] → blog posts, user profiles, product pages
[...slug] → docs, CMS pages, file trees — unknown depth
[[...slug]] → the above, plus an index page at the same route
generateStaticParams → content that's known at build time and changes rarely
(marketing pages, docs, published posts)
dynamicParams: false → a genuinely closed set (plans, locales, countries)
searchParams → filters, pagination, sort order, search queries
(anything that shouldn't create a new "page")
Rule of thumb: path params identify a resource, query params modify a view of it.
/products/nike-air-max ← which product (params)
/products?category=shoes&sort=price ← how to list them (searchParams)
🏋️ Mini Practice Problems
Problem 1: Match the routes
Given these files, which handles each URL — and what are the params?
app/shop/page.tsx
app/shop/[category]/page.tsx
app/shop/[category]/[id]/page.tsx
app/shop/[...rest]/page.tsx
- A.
/shop - B.
/shop/shoes - C.
/shop/shoes/123 - D.
/shop/shoes/nike/air-max/2026
Problem 2: Migrate to Next.js 16
// app/users/[id]/page.tsx
export default function Page({
params,
searchParams,
}: {
params: { id: string }
searchParams: { tab?: string }
}) {
const user = use(getUser(params.id))
return <Profile user={user} tab={searchParams.tab ?? 'overview'} />
}
Problem 3: Why is nothing static?
generateStaticParams runs (you added a console.log and saw it) but next build still shows every route as dynamic:
export async function generateStaticParams() {
const users = await db.user.findMany()
return users.map((u) => ({ userId: u.id }))
}
The folder is app/users/[id]/page.tsx. Two bugs — find both.
Problem 4: Design the routes
An e-commerce site needs:
/products— all products/products/shoes— one category/products/shoes/running— a subcategory/products/shoes/running/nike-pegasus— a product detail page
Which bracket syntax, how many files, and how would you separate the listing pages from the detail page?
💼 Interview Notes
Common Questions
Q: What's the difference between [slug], [...slug], and [[...slug]]?
[slug] matches exactly one segment and gives a string. [...slug] matches one or more and gives an array. [[...slug]] matches zero or more — the array is undefined at the parent path.
Q: Why did params become a Promise in Next.js 15/16?
So Next.js can begin rendering and streaming the request-independent parts of a page before route parameters are resolved. Making request data async is what enables Partial Prerendering.
Q: What does generateStaticParams do?
Returns the list of param values to pre-render at build time. Those routes become static HTML served from a CDN. Routes not in the list are still rendered on demand unless dynamicParams = false.
Q: When would you set dynamicParams = false?
When the set of valid params is closed — pricing tiers, supported locales, a fixed country list. It turns unexpected URLs into 404s instead of failed renders. Note that it's a legacy segment config: with cacheComponents: true it's rejected at build time, and you validate the param and call notFound() instead.
Q: Difference between params and searchParams?
params comes from the URL path and identifies a resource; it's available to pages, layouts, and route handlers, and can be pre-rendered. searchParams comes from the query string, modifies a view, is only passed to pages, and makes the route dynamic because it isn't known at build time.
Q: Are route params safe to use directly? No. They're user-controlled strings. Validate them before using them in file paths, database queries, or redirects.
🏢 Asked at Companies
- Vercel: "Ten million product pages. How do you decide what to pre-render?"
- Shopify: "Design the routing for a storefront with arbitrarily nested collections."
- Netflix: "Why is
paramsa Promise now, and what would break if it weren't?" - Airbnb: "A search page needs filters in the URL, shareable, and SEO-indexable. Path params or query params? Defend it."
📊 Visual Memory Aid
BRACKET SYNTAX CHEAT SHEET
app/blog/[slug]/page.tsx
/blog/hello → { slug: 'hello' }
/blog/a/b → ✗ no match
/blog → ✗ no match
app/docs/[...slug]/page.tsx
/docs/a → { slug: ['a'] }
/docs/a/b/c → { slug: ['a','b','c'] }
/docs → ✗ no match
app/shop/[[...f]]/page.tsx
/shop → { f: undefined } ← the difference
/shop/a → { f: ['a'] }
/shop/a/b → { f: ['a','b'] }
STATIC vs DYNAMIC
generateStaticParams returns ['a','b']
dynamicParams = true (default)
/post/a ──► built at build time ⚡ static
/post/b ──► built at build time ⚡ static
/post/c ──► rendered on request, then cached
dynamicParams = false
/post/c ──► 404
⚠️ with cacheComponents: true, dynamicParams is REJECTED.
/post/c gets the App Shell + fills in behind it.
To 404, validate the param and call notFound().
PARAMS vs SEARCHPARAMS
params searchParams
────── ────────────
source URL path query string
identifies a resource a view of it
page ✅ ✅
layout ✅ ❌
route.ts ✅ (context) ❌ (use request.url)
prerender ✅ ❌ (dynamic)
in 16 Promise Promise
🎯 Key Takeaways
- Three syntaxes:
[x]for one segment,[...x]for one-or-more (array),[[...x]]for zero-or-more (array orundefined). paramsandsearchParamsare Promises in Next.js 16. Every page reading them isasyncand mustawait. This is the change that breaks the most copied code.generateStaticParamsturns dynamic routes into static HTML at build time. You don't have to list everything — pre-build the popular ones and letdynamicParamshandle the tail.- Use
PageProps<'/route'>,LayoutProps, andRouteContextinstead of hand-written types. They're generated from your real folder structure, so typos fail to compile. - Path params identify, query params modify — and both are untrusted user input that needs validating before it reaches a database or the filesystem.
Next Chapter: Route Groups & Organization →
Practice: Build a /docs/[...slug] route backed by markdown files on disk, with generateStaticParams pre-rendering every file, a breadcrumb built from the slug array, notFound() for missing docs, and a regex guard against path traversal.