Skip to main content

Dynamic routes and dynamic segments in Next.js

Dynamic routes in Next.js use bracket notation in file names to match variable URL segments, capturing them as params for rendering pages without creating a separate file per path.

Theory

TL;DR

  • File app/blog/[slug]/page.tsx handles /blog/anything from one file
  • The URL segment maps to params as a string: /blog/hello gives slug = "hello"
  • [...slug] catches one or more segments as an array; [[...slug]] catches zero or more
  • Always await params in App Router (Next.js 13+) - it is a Promise, not a plain object
  • Combine with generateStaticParams to pre-build known paths at build time

Quick example

tsx
// app/blog/[slug]/page.tsx export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; // /blog/hello-world → slug = "hello-world" // /blog/my-post → slug = "my-post" return <h1>Post: {slug}</h1>; }

One file. Unlimited URLs. That is the whole point.

When to use

  • Fixed known paths (/about, /contact) - static files are faster to build and cache better
  • User-generated content (post slugs, product handles) - use [slug], it scales to any number of items
  • Nested unknown paths (docs trees, category hierarchies) - use [...slug] to capture everything as an array
  • Optional segments where /shop and /shop/clothes/men both need to work - use [[...slug]]
  • Paginated lists - [page]/page.tsx gives you /posts/1, /posts/2 from one file

Segment patterns

Three patterns cover every case you will run into.

[slug] - single segment

Matches exactly one URL part. /blog/hello works. /blog/hello/world returns 404.

app/blog/[slug]/page.tsx → /blog/hello-world ✓ → /blog/hello/world ✗ (404)

[...slug] - catch-all

Matches one or more segments, gives you an array. Zero segments (just /docs) returns 404.

app/docs/[...slug]/page.tsx → /docs/react ✓ slug = ["react"] → /docs/react/hooks ✓ slug = ["react", "hooks"] → /docs ✗ (404)

[[...slug]] - optional catch-all

Same as catch-all, but also matches zero segments. The root path works too.

app/shop/[[...categories]]/page.tsx → /shop ✓ categories = undefined → /shop/clothes ✓ categories = ["clothes"] → /shop/clothes/men ✓ categories = ["clothes", "men"]

Multiple dynamic segments

You can nest bracket folders. Each one adds a key to params.

app/[locale]/blog/[slug]/page.tsx → /en/blog/hello, /ua/blog/hello
tsx
export default async function BlogPost({ params, }: { params: Promise<{ locale: string; slug: string }>; }) { const { locale, slug } = await params; // /en/blog/hello → { locale: "en", slug: "hello" } const post = await getPost(slug, locale); return <article>{post.title}</article>; }

generateStaticParams

By default, dynamic routes render on demand at request time. If you know the slugs ahead of time, export generateStaticParams to pre-build those pages at build time. Unknown slugs still fall through to server rendering.

tsx
// app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await getAllPosts(); return posts.map((post) => ({ slug: post.slug, })); } // Builds /blog/post-1, /blog/post-2 at build time // New slugs still work via server rendering

This is what getStaticPaths did in the Pages Router, just with a cleaner API.

How Next.js resolves dynamic routes

The file-based router scans app/ at build or dev start, mapping bracketed folders to regex patterns internally. [slug] becomes /([^/]+), [...slug] becomes /(.*+). On each request, the router matches the URL, extracts captures into a params Promise, and passes it to your page.tsx as a Server Component prop.

One detail worth knowing: if you have both app/blog/featured/page.tsx and app/blog/[slug]/page.tsx, the static route wins. Next.js always prefers exact matches over dynamic ones.

Common mistakes

1. Forgetting await params

params is a Promise in App Router. Destructuring it without await gives you undefined on every property.

tsx
// Wrong const { slug } = params; // undefined // Correct const { slug } = await params;

This is the most reported issue after upgrading from Next.js 12.

2. Using useRouter() from next/router in App Router

tsx
// Wrong - Pages Router pattern import { useRouter } from 'next/router'; const { slug } = useRouter().query; // Correct - App Router const { slug } = await params; // in a Server Component

3. Static export with dynamic routes

Setting output: 'export' in next.config.js breaks all dynamic routes that lack generateStaticParams. Next.js cannot generate HTML files for paths it does not know at build time.

4. Assuming params are numbers

All params values come in as strings. Even /users/42 gives id = "42", not 42.

tsx
// Wrong const id = params.id + 1; // "421" (string concatenation) // Correct const id = Number(await params.id);

5. Catch-all without handling the empty case

[[...slug]] produces categories = undefined on the root path. If your component calls categories.length, it crashes.

tsx
const { categories = [] } = await params; // default to empty array

Real-world usage

  • Vercel Commerce template: app/products/[handle]/page.tsx for Shopify product slugs
  • Nextra docs: [[...slug]]/page.tsx for MDX documentation trees
  • Supabase Starter: app/users/[id]/page.tsx for user profile pages
  • T3 Stack apps: app/posts/[id]/page.tsx with tRPC queries behind it

Follow-up questions

Q: What file structure handles /blog/2024/01/post-1?
A: app/blog/[year]/[month]/[slug]/page.tsx. The params object gives { year: "2024", month: "01", slug: "post-1" }. All values are strings, even the numeric-looking ones.

Q: What is the difference between [...slug] and [[...slug]]?
A: [...slug] requires at least one segment, so hitting /docs with only the catch-all file returns 404. [[...slug]] also matches zero segments, so /docs works and gives slug = undefined.

Q: How do you return a 404 when a dynamic slug has no matching data?
A: Add not-found.tsx next to your page.tsx, then call notFound() from next/navigation inside the page when your data fetch returns nothing. Next.js renders the not-found UI automatically.

Q: Can generateStaticParams and server rendering work together on the same route?
A: Yes. Paths you return from generateStaticParams get built at compile time. Any slug outside that list still gets server-rendered on the first request. Set export const dynamicParams = false to explicitly 404 unknown slugs instead.

Q: (Senior) How would you handle caching for a dynamic route that fetches from a CMS?
A: Use fetch with next: { revalidate: 3600 } for ISR - the cache refreshes every hour. For on-demand purging, tag fetches with next: { tags: ['posts'] } and call revalidateTag('posts') from a Server Action or Route Handler when content changes in the CMS. That way you get fresh data in seconds, not after the next scheduled revalidation.

Examples

Basic: Blog post page

tsx
// app/blog/[slug]/page.tsx import { notFound } from 'next/navigation'; async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`, { next: { revalidate: 3600 }, }); if (!res.ok) return null; return res.json(); } export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); } // /blog/nextjs-dynamic-routes → fetches and renders that specific post

The notFound() call is what turns a missing slug into an actual 404 page instead of a blank render.

Intermediate: Optional catch-all for a docs site

tsx
// app/docs/[[...slug]]/page.tsx export default async function Docs({ params, }: { params: Promise<{ slug?: string[] }>; }) { const { slug = [] } = await params; // /docs → slug = [] (docs home) // /docs/nextjs → slug = ["nextjs"] // /docs/nextjs/app-router → slug = ["nextjs", "app-router"] const content = await getDocContent(slug); return ( <div> <h1>{slug.length ? slug.join(' / ') : 'Documentation'}</h1> <div>{content}</div> </div> ); }

The = [] default on the destructuring matters. Without it, slug is undefined at /docs and slug.length throws immediately.

Advanced: Static generation with dynamic fallback

tsx
// app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await getAllPosts(); // Pre-build only the 20 most recent posts at build time return posts.slice(0, 20).map((post) => ({ slug: post.slug })); } // Older posts still render via the server on first request export const dynamicParams = true; // default behavior export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); return <article><h1>{post.title}</h1></article>; }

Pre-building the most-visited pages at build time while still serving everything else dynamically is a pattern I have used on several content-heavy Next.js projects. Build times stay short and nothing returns 404 unexpectedly.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?