Next.js Interview Prep
Routing

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 generateStaticParams ties 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


Dynamic Routes & Catch-All thumbnail


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 params in both pages and layouts
  • How generateStaticParams pre-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:

  1. Add a separate app/docs/page.tsx for the index
  2. 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.tsx wins over app/[[...slug]]/page.tsx for /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.tsx at the parent level
  • Example: /docs/[...slug] where /docs has 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 create app/docs/[...slug]/page.tsx and expect /docs to work. It won't — that path has zero segments and catch-all requires at least one. Either add a separate app/docs/page.tsx or switch to [[...slug]].

  • Treating params.slug as a string in catch-all routes. With [...slug] and [[...slug]], slug is an array, not a string. Writing fetch(/api/docs/${params.slug}) will produce something like /api/docs/intro,setup because JavaScript coerces arrays to comma-separated strings. Always use params.slug.join("/").

  • Not awaiting params in Next.js 15+. Since Next.js 15, params is a Promise. Accessing params.slug directly without await returns 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-level app/[[...slug]]/page.tsx catches 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 separate app/docs/page.tsx or 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 dynamicParams is true (the default), the page is rendered on-demand at request time and then cached for subsequent visits — similar to ISR's fallback behavior. If dynamicParams is false, any path not returned by generateStaticParams results 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 (and searchParams) became asynchronous to enable optimizations in the rendering pipeline. Previously synchronous access like params.slug must now be const { slug } = await params. Code that doesn't await will 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 /blog path. The [[...slug]] optional catch-all already handles the zero-segment case (which is the same as /blog), so having a separate page.tsx at 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 separate page.tsx for 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.

On this page