Dynamic Routes & Catch-All
The URL Pattern Toolkit
LinkedIn Hook
"Can you explain the difference between [slug], [...slug], and [[...slug]] in Next.js?"
This question trips up 80% of candidates in frontend interviews. They know the bracket syntax exists, but they can't explain when to use each one or how
generateStaticParamsties into it.The truth is, these three patterns cover every URL structure you'll ever need — from a simple blog post to a deeply nested documentation site. And once you understand the mental model, you'll never confuse them again.
In Lesson 5.1, I break down dynamic segments, catch-all routes, and optional catch-all routes with real code examples, a comparison table, and the exact interview answers that impress hiring managers.
Read the full lesson -> [link]
#NextJS #WebDevelopment #Routing #DynamicRoutes #FrontendDevelopment #InterviewPrep #React
What You'll Learn
- How
[slug]dynamic segments match exactly one URL segment - How
[...slug]catch-all routes match one or more segments - How
[[...slug]]optional catch-all routes match zero or more segments - How to access route parameters via
paramsin both pages and layouts - How
generateStaticParamspre-renders dynamic routes at build time - When to choose each pattern in real-world applications
The Hotel Elevator Analogy
Think of your Next.js routing as a hotel elevator system.
[slug] (Dynamic Segment) = A single-floor elevator button. You press "Floor 5" and it takes you to exactly Floor 5. One press, one floor, one destination. If you try to press "Floor 5, then Floor 7" in one go, it doesn't work — that button only handles one floor at a time.
[...slug] (Catch-All) = An express elevator with a floor range. You press "Floors 5 through 12" and the elevator knows the full path. It captures the entire sequence. But you must specify at least one floor — pressing nothing gives you an error.
[[...slug]] (Optional Catch-All) = The same express elevator, but with a lobby default. If you step in without pressing anything, it takes you to the lobby (the index page). If you press floors, it captures all of them. Zero or more — your choice.
+-------------------------------------------------------------+
| THE ELEVATOR MODEL |
+-------------------------------------------------------------+
| |
| [slug] Press ONE button /blog/hello -> OK |
| /blog/a/b -> 404 |
| /blog -> 404 |
| |
| [...slug] Press ONE or MORE /docs/intro -> OK |
| /docs/a/b/c -> OK |
| /docs -> 404 |
| |
| [[...slug]] Press ZERO or MORE /shop -> OK |
| /shop/shirts -> OK |
| /shop/a/b/c -> OK |
| |
+-------------------------------------------------------------+
This mental model is all you need. Now let's see how each one works in code.
[slug] — Dynamic Segments
A dynamic segment matches exactly one URL segment. It's the most common pattern and what you'll use for blog posts, product pages, user profiles — any route where one variable piece sits in the URL.
Folder Structure
app/
blog/
[slug]/
page.tsx // matches /blog/anything
Code Example
// app/blog/[slug]/page.tsx
// The params object is passed automatically by Next.js
// In Next.js 15+, params is a Promise that must be awaited
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// slug is always a string — never an array
// /blog/hello-world -> slug = "hello-world"
// /blog/my-first-post -> slug = "my-first-post"
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// Helper function to simulate fetching a post
async function getPost(slug: string) {
// In a real app, this would be a database query or API call
return {
title: slug.replace(/-/g, " "),
content: `Content for ${slug}`,
};
}
Multiple Dynamic Segments
You can have more than one dynamic segment in a single path:
app/
shop/
[category]/
[productId]/
page.tsx // matches /shop/shoes/nike-air-max
// app/shop/[category]/[productId]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ category: string; productId: string }>;
}) {
const { category, productId } = await params;
// /shop/shoes/nike-air-max
// category = "shoes"
// productId = "nike-air-max"
// /shop/electronics/iphone-15
// category = "electronics"
// productId = "iphone-15"
return (
<div>
<p>Category: {category}</p>
<p>Product: {productId}</p>
</div>
);
}
Key rule: /shop/shoes alone would NOT match this route. Both segments are required. You'd need a separate app/shop/[category]/page.tsx to handle the category listing page.
[...slug] — Catch-All Routes
A catch-all route matches one or more URL segments. The parameter becomes an array of strings instead of a single string. This is perfect for documentation sites, nested category trees, or any URL where the depth varies.
Folder Structure
app/
docs/
[...slug]/
page.tsx // matches /docs/anything and /docs/a/b/c/d
Code Example
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params;
// slug is ALWAYS an array (never undefined, never a string)
// /docs/getting-started -> slug = ["getting-started"]
// /docs/api/reference -> slug = ["api", "reference"]
// /docs/guides/auth/oauth/google -> slug = ["guides", "auth", "oauth", "google"]
// Build a breadcrumb from the segments
const breadcrumb = slug.join(" > ");
// Use the last segment as the page identifier
const pageId = slug[slug.length - 1];
// Use the full path to locate the document
const docPath = slug.join("/");
return (
<div>
<nav>Breadcrumb: {breadcrumb}</nav>
<h1>Document: {pageId}</h1>
<p>Full path: /docs/{docPath}</p>
</div>
);
}
What Catch-All Does NOT Match
This is the critical detail interviewers look for:
/docs -> 404! No segments to catch.
/docs/ -> 404! Trailing slash, still no segments.
/docs/intro -> OK -> slug = ["intro"]
/docs/intro/setup -> OK -> slug = ["intro", "setup"]
If you need /docs (the bare path) to also work, you have two options:
- Add a separate
app/docs/page.tsxfor the index - Use optional catch-all
[[...slug]]instead
[[...slug]] — Optional Catch-All Routes
The double-bracket syntax makes the catch-all optional. It matches zero or more URL segments. The parameter is either an array of strings or undefined (when no segments are provided).
Folder Structure
app/
shop/
[[...slug]]/
page.tsx // matches /shop AND /shop/anything AND /shop/a/b/c
Code Example
// app/shop/[[...slug]]/page.tsx
export default async function ShopPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
// slug is an array OR undefined
// /shop -> slug = undefined
// /shop/shirts -> slug = ["shirts"]
// /shop/shirts/summer -> slug = ["shirts", "summer"]
// /shop/shirts/summer/sale -> slug = ["shirts", "summer", "sale"]
if (!slug) {
// No segments — render the shop homepage
return <h1>Welcome to the Shop</h1>;
}
if (slug.length === 1) {
// One segment — render a category page
return <h1>Category: {slug[0]}</h1>;
}
// Multiple segments — render a filtered/nested view
return (
<div>
<h1>Browsing: {slug.join(" / ")}</h1>
<p>Depth: {slug.length} levels</p>
</div>
);
}
Real-World Use Case: CMS Pages
Optional catch-all is perfect for CMS-driven sites where the URL structure is unpredictable:
// app/[[...slug]]/page.tsx
// This single file handles EVERY page on the site
export default async function CmsPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
// / -> slug = undefined -> fetch homepage
// /about -> slug = ["about"] -> fetch "about" page
// /services/web-design -> slug = ["services", "web-design"]
// /blog/2024/march/my-post -> slug = ["blog", "2024", "march", "my-post"]
const path = slug ? slug.join("/") : "";
const page = await fetchCmsPage(path);
if (!page) {
return notFound(); // triggers the not-found.tsx boundary
}
return <div dangerouslySetInnerHTML={{ __html: page.html }} />;
}
async function fetchCmsPage(path: string) {
// Query your CMS with the full path
// Returns null if page doesn't exist
return { html: `<h1>Page at /${path}</h1>` };
}
Warning: Placing
[[...slug]]at the root (app/[[...slug]]/page.tsx) will catch every URL on your site. This can conflict with other routes if you're not careful. Next.js resolves conflicts by giving priority to more specific routes (e.g.,app/blog/page.tsxwins overapp/[[...slug]]/page.tsxfor/blog).
Accessing params — Pages, Layouts, and Route Handlers
The params object is available in multiple places. Here's where and how:
// In a page component
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ...
}
// In a layout component
export default async function Layout({
params,
children,
}: {
params: Promise<{ slug: string }>;
children: React.ReactNode;
}) {
const { slug } = await params;
// Layout receives params for its own dynamic segment
// and any parent dynamic segments
return <div>{children}</div>;
}
// In generateMetadata (for dynamic SEO)
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return {
title: `Post: ${slug}`,
};
}
// In a route handler (API route)
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
return Response.json({ slug });
}
Important in Next.js 15+: The params object is a Promise. You must await it before accessing properties. This was a major change from Next.js 14, where params was a plain synchronous object. Interviewers love testing if you know this distinction.
generateStaticParams — Pre-Rendering Dynamic Routes
By default, dynamic routes are rendered on-demand (at request time). But what if you know all possible values ahead of time? generateStaticParams tells Next.js to pre-render those pages at build time, turning dynamic routes into static pages.
Basic Usage
// app/blog/[slug]/page.tsx
// This function runs at BUILD TIME
// It returns an array of all possible param values
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((res) =>
res.json()
);
// Each object must match the shape of the dynamic segment
// For [slug], return { slug: string }
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
// Output: [
// { slug: "hello-world" },
// { slug: "nextjs-routing" },
// { slug: "react-server-components" },
// ]
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article><h1>{post.title}</h1></article>;
}
With Catch-All Routes
// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
// For catch-all, each entry has slug as a string ARRAY
return [
{ slug: ["getting-started"] },
{ slug: ["api", "reference"] },
{ slug: ["guides", "authentication", "oauth"] },
];
// This pre-renders:
// /docs/getting-started
// /docs/api/reference
// /docs/guides/authentication/oauth
}
With Multiple Dynamic Segments
// app/shop/[category]/[productId]/page.tsx
export async function generateStaticParams() {
// Return all combinations of category + productId
return [
{ category: "shoes", productId: "nike-air-max" },
{ category: "shoes", productId: "adidas-ultraboost" },
{ category: "electronics", productId: "iphone-15" },
];
}
The dynamicParams Config
What happens when someone visits a URL that was NOT pre-rendered by generateStaticParams?
// app/blog/[slug]/page.tsx
// true (default) — unknown slugs are rendered on-demand (SSR fallback)
export const dynamicParams = true;
// false — unknown slugs return 404
export const dynamicParams = false;
This is a frequent interview question: "If you use generateStaticParams for 100 blog posts but publish a 101st, what happens?" The answer depends on dynamicParams. With true (default), the 101st post is rendered on-demand and then cached. With false, it returns a 404 until the next build.
Comparison Table — The Interview Cheat Sheet
+----------------------+-------------------+-------------------+-------------------+
| | [slug] | [...slug] | [[...slug]] |
+----------------------+-------------------+-------------------+-------------------+
| Segments matched | Exactly 1 | 1 or more | 0 or more |
+----------------------+-------------------+-------------------+-------------------+
| params type | string | string[] | string[] | |
| | | | undefined |
+----------------------+-------------------+-------------------+-------------------+
| /route | 404 | 404 | OK (slug = |
| | | | undefined) |
+----------------------+-------------------+-------------------+-------------------+
| /route/a | OK (slug = "a") | OK (slug = | OK (slug = |
| | | ["a"]) | ["a"]) |
+----------------------+-------------------+-------------------+-------------------+
| /route/a/b | 404 | OK (slug = | OK (slug = |
| | | ["a","b"]) | ["a","b"]) |
+----------------------+-------------------+-------------------+-------------------+
| /route/a/b/c | 404 | OK (slug = | OK (slug = |
| | | ["a","b","c"]) | ["a","b","c"]) |
+----------------------+-------------------+-------------------+-------------------+
| Common use case | Blog posts, | Docs, nested | CMS pages, |
| | user profiles | categories, | catch-all with |
| | | file paths | index page |
+----------------------+-------------------+-------------------+-------------------+
| Folder name | [slug] | [...slug] | [[...slug]] |
+----------------------+-------------------+-------------------+-------------------+
| generateStaticParams | { slug: "x" } | { slug: ["x"] } | { slug: ["x"] } |
| return shape | | | (no entry for |
| | | | index) |
+----------------------+-------------------+-------------------+-------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Three vertical columns side by side. Left column: single green (#10b981) bracket icon with label '[slug]' and one URL segment highlighted. Middle column: green spread brackets '[...slug]' with multiple URL segments fanning out like a tree. Right column: double brackets '[[...slug]]' with the same tree plus a root node labeled 'index'. Arrows from URL examples to each column. White monospace labels. Title: 'Dynamic Route Patterns Compared'."
When to Use Each Pattern
Choosing the right pattern is a decision you should be able to justify in an interview. Here's the decision framework:
Use [slug] when:
- Your URL has a fixed structure with known depth
- Each dynamic part maps to a single entity (one post, one user, one product)
- Example:
/blog/[slug],/users/[id],/products/[sku]
Use [...slug] when:
- URL depth is variable and always has at least one segment
- You need to handle hierarchical paths (docs, file trees, nested categories)
- You have a separate index page handled by a
page.tsxat the parent level - Example:
/docs/[...slug]where/docshas its own page
Use [[...slug]] when:
- URL depth is variable AND the root path needs to be handled by the same component
- You want a single component to render both the index and all sub-paths
- CMS-driven sites where path structure is entirely dynamic
- Example:
/[[...slug]]for a headless CMS site
Common Mistakes
-
Forgetting that
[...slug]does NOT match the bare route. Developers createapp/docs/[...slug]/page.tsxand expect/docsto work. It won't — that path has zero segments and catch-all requires at least one. Either add a separateapp/docs/page.tsxor switch to[[...slug]]. -
Treating
params.slugas a string in catch-all routes. With[...slug]and[[...slug]],slugis an array, not a string. Writingfetch(/api/docs/${params.slug})will produce something like/api/docs/intro,setupbecause JavaScript coerces arrays to comma-separated strings. Always useparams.slug.join("/"). -
Not awaiting
paramsin Next.js 15+. Since Next.js 15,paramsis aPromise. Accessingparams.slugdirectly withoutawaitreturns a Promise object, not the value. This causes silent bugs where your page renders[object Promise]instead of the actual slug. -
Putting
[[...slug]]at the root without understanding route priority. A root-levelapp/[[...slug]]/page.tsxcatches everything, but Next.js gives priority to more specific routes. Still, it can cause confusion during development when you expect a different page to render and the catch-all intercepts the request instead. Be explicit about which routes exist.
Interview Questions
Q: What is the difference between [slug], [...slug], and [[...slug]] in Next.js routing?
(Covered in the main content above.)
Q: What happens if a user visits /docs when you only have app/docs/[...slug]/page.tsx?
They get a 404. The catch-all route
[...slug]requires at least one segment. To handle/docs, you either need a separateapp/docs/page.tsxor you change to[[...slug]]which makes the segments optional (zero or more).
Q: How does generateStaticParams work with dynamicParams? What happens when a user visits a path not listed in generateStaticParams?
If
dynamicParamsistrue(the default), the page is rendered on-demand at request time and then cached for subsequent visits — similar to ISR's fallback behavior. IfdynamicParamsisfalse, any path not returned bygenerateStaticParamsresults in a 404. This is useful for sites with a known, fixed set of pages where you want to prevent any unlisted URLs from being rendered.
Q: In Next.js 15, why is params a Promise? How does this affect existing code?
Starting in Next.js 15,
params(andsearchParams) became asynchronous to enable optimizations in the rendering pipeline. Previously synchronous access likeparams.slugmust now beconst { slug } = await params. Code that doesn'tawaitwill receive a Promise object instead of the actual value. Next.js provides a codemod (npx @next/codemod@latest next-async-request-api) to automate the migration.
Q: Can you have both app/blog/page.tsx and app/blog/[[...slug]]/page.tsx?
No — this creates a conflict. Both would try to handle the
/blogpath. The[[...slug]]optional catch-all already handles the zero-segment case (which is the same as/blog), so having a separatepage.tsxat the same level creates ambiguity. Next.js will throw a build error. Choose one approach: either use[[...slug]]to handle everything (including the index), or use a separatepage.tsxfor the index and[...slug](non-optional) for the sub-paths.
Quick Reference -- Cheat Sheet
+------------------------------------------------------------------+
| NEXT.JS DYNAMIC ROUTES — QUICK REFERENCE |
+------------------------------------------------------------------+
| |
| SYNTAX MATCHES PARAMS TYPE INDEX ROUTE? |
| ------ ------- ----------- ------------ |
| [slug] /x string No |
| [a]/[b] /x/y { a, b } No |
| [...slug] /x, /x/y/z string[] No (need page) |
| [[...slug]] /, /x, /x/y/z string[]|undef Yes |
| |
+------------------------------------------------------------------+
| |
| generateStaticParams: |
| [slug] -> return [{ slug: "a" }, { slug: "b" }] |
| [...slug] -> return [{ slug: ["a","b"] }] |
| [[...slug]] -> return [{ slug: ["a","b"] }] |
| |
| dynamicParams: |
| true (default) -> unknown params = render on-demand |
| false -> unknown params = 404 |
| |
| Next.js 15+: params is a Promise — always await it! |
| |
+------------------------------------------------------------------+
Previous: Lesson 4.4 — Server Actions for Mutations Next: Lesson 5.2 — Layouts & Templates
This is Lesson 5.1 of the Next.js Interview Prep Course — 8 chapters, 33 lessons.