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.tsxhandles/blog/anythingfrom one file - The URL segment maps to
paramsas a string:/blog/hellogivesslug = "hello" [...slug]catches one or more segments as an array;[[...slug]]catches zero or more- Always
await paramsin App Router (Next.js 13+) - it is a Promise, not a plain object - Combine with
generateStaticParamsto pre-build known paths at build time
Quick example
// 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
/shopand/shop/clothes/menboth need to work - use[[...slug]] - Paginated lists -
[page]/page.tsxgives you/posts/1,/posts/2from 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
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.
// 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 renderingThis 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.
// 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
// Wrong - Pages Router pattern
import { useRouter } from 'next/router';
const { slug } = useRouter().query;
// Correct - App Router
const { slug } = await params; // in a Server Component3. 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.
// 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.
const { categories = [] } = await params; // default to empty arrayReal-world usage
- Vercel Commerce template:
app/products/[handle]/page.tsxfor Shopify product slugs - Nextra docs:
[[...slug]]/page.tsxfor MDX documentation trees - Supabase Starter:
app/users/[id]/page.tsxfor user profile pages - T3 Stack apps:
app/posts/[id]/page.tsxwith 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
// 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 postThe 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.