Dev Logs
/Next.js/ Chapter 5: Dynamic Routes & Params
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
    • Plain English Explanation
    • The three bracket syntaxes
    • [slug] — one segment
    • [...slug] — catch-all, one or more segments
    • [[...slug]] — optional catch-all, zero or more segments
    • Which to pick
    • params is a Promise now
    • Why did this change?
    • The consequences
    • The typed props helpers
    • generateStaticParams — pre-render at build time
    • Catch-all routes
    • Multiple dynamic segments
    • dynamicParams — what about URLs you didn't list?
    • A practical pattern: build the popular ones
    • searchParams
    • Handling missing content
    • next/root-params
    • Common Pitfalls
    • . Synchronous params
    • . Forgetting String() in generateStaticParams
    • . Mismatched key names
    • . Treating [...slug] as a string
    • . Not handling undefined in optional catch-alls
    • . Reading searchParams in a layout
    • . Trusting params from the URL
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Match the routes
    • Problem 2: Migrate to Next.js 16
    • Problem 3: Why is nothing static?
    • Problem 4: Design the routes
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🎯 Chapter 5: Dynamic Routes & Params

One file that serves a million URLs, why params is 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:

  1. Three bracket syntaxes, for matching one segment, many segments, or optionally-zero segments.
  2. params is a Promise in Next.js 16 — this breaks nearly every tutorial written before late 2025.
  3. generateStaticParams lets 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
URLparams
/blog/hello-world{ slug: 'hello-world' }
/blog/2026-review{ slug: '2026-review' }
/blog/a/b❌ no match (two segments)
/blog❌ no match (zero segments)
tsx
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  return <h1>Post: {slug}</h1>
}
jsx
// 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
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>
}
jsx
// 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
URLparams
/docs/intro{ slug: ['intro'] }
/docs/api/auth/login{ slug: ['api', 'auth', 'login'] }
/docs❌ no match

Note that slug is now an array.

tsx
// 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>
  )
}
jsx
// 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
URLparams
/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.

tsx
// 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>
  )
}
jsx
// 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(), and draftMode() 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:

bash
npx @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:

tsx
// ✅
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
}

In Client Components, use() unwraps the promise:

tsx
// 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>
}
jsx
// 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:

tsx
'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:

tsx
// 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:

tsx
// 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>
}
tsx
// 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>
}
ts
// 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."

tsx
// 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>
}
jsx
// 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:

tsx
export async function generateStaticParams() {
  const products = await getProducts()
  return products.map((p) => ({ id: String(p.id) }))   // ← String() matters
}

Catch-all routes

Return arrays:

tsx
// 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:

tsx
// 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:

tsx
// app/shop/[category]/layout.tsx
export async function generateStaticParams() {
  const categories = await getCategories()
  return categories.map((c) => ({ category: c.slug }))
}
tsx
// 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?

tsx
// app/blog/[slug]/page.tsx
export const dynamicParams = true   // default
ValueBehavior for an un-generated param
true (default)Render on demand, then cache. New posts work without a rebuild.
falseReturn 404. Only the listed params exist.

Use false when the set is genuinely closed — a fixed list of countries, plan tiers, or locales:

tsx
// 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

dynamicParams is a legacy route segment config. If you have cacheComponents: true in your next.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: true behaviour by default. To 404 an unlisted param, check for the record and call notFound():

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:

tsx
// 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:

tsx
// 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} />
}
jsx
// 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:

ts
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:

tsx
// ❌ 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:

tsx
// 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>
}
jsx
// 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>
}
tsx
// 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
ts
// 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_rootParams was removed. Use next/root-params instead.

⚠️ 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

tsx
// ❌ 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

tsx
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

tsx
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.

tsx
// ❌
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

tsx
// 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:

tsx
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 params a 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

  1. Three syntaxes: [x] for one segment, [...x] for one-or-more (array), [[...x]] for zero-or-more (array or undefined).
  2. params and searchParams are Promises in Next.js 16. Every page reading them is async and must await. This is the change that breaks the most copied code.
  3. generateStaticParams turns dynamic routes into static HTML at build time. You don't have to list everything — pre-build the popular ones and let dynamicParams handle the tail.
  4. Use PageProps<'/route'>, LayoutProps, and RouteContext instead of hand-written types. They're generated from your real folder structure, so typos fail to compile.
  5. 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.


PreviousChapter 4: Linking & NavigationNextChapter 6: Route Groups & Organization

Open source, free forever. Built by iammhador.

Contribute on GitHub