In the Next.js App Router, what is the difference between a [...slug] catch-all segment and a [[...slug]] optional catch-all, and what does params.slug hold in each case?
answer
- one bracket pair versus two
- how many segments each consumes
- the bare parent path is the difference
- the value's runtime type is an array
- undefined at the root, not empty
basics
~20 sA [...slug] catch-all matches one or more path segments and gives params.slug as an array of strings. A [[...slug]] optional catch-all matches the same URLs plus the bare parent path, where params.slug is undefined rather than an empty array.
solid answer
~40 sBoth forms collapse a variable number of path segments into one route, and both hand you an **array**. With `app/shop/[...slug]/page.tsx`, `/shop/shoes` gives `{ slug: ['shoes'] }` and `/shop/shoes/running/42` gives `{ slug: ['shoes','running','42'] }` — but bare `/shop` does **not** match, so you would still need a separate `app/shop/page.tsx`. The double brackets make the segment optional: `app/shop/[[...slug]]/page.tsx` matches everything above *and* `/shop` itself, and in that one case `params.slug` is `undefined` — not `[]`, which is the detail interviewers probe. That is the whole difference. Reach for the optional form when one file legitimately owns a whole subtree including its root, such as a docs site or CMS-driven pages; reach for the plain catch-all when the root has its own distinct page.
code
tsx · 12 lines// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
// /docs -> slug is undefined -> 'index'
// /docs/api/auth -> slug is ['api','auth'] -> 'api/auth'
const path = slug?.join('/') ?? 'index'
return <article data-path={path}>{path}</article>
}go deeper
Recall the shapes: one bracket pair matches one segment, three dots match many, and double brackets also match the parent URL with nothing after it. Say that catch-alls hand you an array.
Explain the exact runtime values, including that the optional form yields undefined rather than an empty array at the root, and pick the right form for a docs site versus a shop with its own landing page.
Show the operational side: normalise the segments once, cap or validate depth because the array is attacker-controlled, and get the array shape right when enumerating catch-all paths for prerendering.
Own the URL taxonomy. Argue when a whole subtree should be one CMS-driven catch-all versus explicit routes per section, and what that choice costs in redirect management, ownership boundaries, and build-time enumeration.
## The three bracket forms The App Router has exactly three ways to write a variable folder name, and they differ only in how many segments they consume: | Folder | Matches | `params` for the example | | --- | --- | --- | | `app/shop/[slug]` | `/shop/a` | `{ slug: 'a' }` | | `app/shop/[...slug]` | `/shop/a`, `/shop/a/b`, `/shop/a/b/c` | `{ slug: ['a','b'] }` for `/shop/a/b` | | `app/shop/[[...slug]]` | all of the above **plus** `/shop` | `{ slug: undefined }` for `/shop` | A single dynamic segment consumes exactly one segment and yields a string. Both catch-all forms consume one *or more* segments and yield a string array. The optional form additionally consumes *zero* segments. ## Reading the value ```tsx export default async function Page({ params, }: { params: Promise<{ slug?: string[] }> }) { const { slug } = await params const path = slug?.join('/') ?? 'index' return <h1>{path}</h1> } ``` Two things to notice. First, since Next 15 `params` is a Promise here just as it is for a plain dynamic segment, so it is awaited. Second, the type for an optional catch-all is `string[] | undefined`, which is why the `?.` and the `??` fallback are not defensive noise — they are the actual contract. ## The undefined-versus-empty-array trap The single most-asked detail on this topic: at the root URL of an optional catch-all, `params.slug` is `undefined`, **not** an empty array. Code written as `slug.length === 0` to detect the root throws a TypeError on the very URL the optional form exists to serve. Write `if (!slug)` or normalise once at the top with `const segments = slug ?? []`. ## Choosing between them Use a **plain catch-all** when the parent path has a genuinely different page. A shop landing page at `/shop` with a merchandising layout, and category paths of arbitrary depth beneath it, is the textbook case: `app/shop/page.tsx` serves the landing page and `app/shop/[...slug]/page.tsx` serves the rest. Use an **optional catch-all** when one renderer legitimately owns the whole subtree including its root. Documentation sites are the canonical example: `app/docs/[[...slug]]/page.tsx` resolves `/docs` to the index article and `/docs/api/auth` to a nested one, all from the same file and the same content lookup. CMS-driven marketing pages, where an editor may create pages at any depth, are the other common case. Note that you cannot have both `app/shop/page.tsx` and `app/shop/[[...slug]]/page.tsx` covering the same root URL — the optional catch-all already claims it, and that collision is exactly what the plain catch-all form avoids. ## Prerendering catch-all routes When you enumerate paths with `generateStaticParams`, the value for a catch-all segment must be an **array**, mirroring the runtime shape: ```ts export function generateStaticParams() { return [{ slug: ['api', 'auth'] }, { slug: ['guides', 'setup'] }] } ``` Returning `{ slug: 'api/auth' }` is wrong: a slash inside a single string is not the same as two segments, and the route will not resolve as you expect. ## Specificity: a catch-all does not swallow its neighbours A frequent worry is that a catch-all will shadow more specific routes in the same folder. It does not. Next matches from most specific to least: a literal folder such as `app/shop/cart/page.tsx` wins over `app/shop/[category]/page.tsx`, which in turn wins over `app/shop/[...slug]/page.tsx`. So you can keep hand-written pages beside a catch-all and they keep serving their exact URLs. ## Validate what comes out Because a catch-all matches arbitrary depth, the array you receive is arbitrary attacker-controlled input of arbitrary length. If those segments are turned into a filesystem path or a CMS lookup key, sanitise them — depth limits and a rejection of unexpected segment values belong in the page, not in the routing layer, which happily matches anything.
- Can you keep both app/shop/page.tsx and app/shop/[[...slug]]/page.tsx in the same project?No — both claim the `/shop` URL, so they collide. Either use the optional catch-all alone and branch inside it when `slug` is `undefined`, or switch to the plain `[...slug]` form and let a dedicated `app/shop/page.tsx` own the root. The plain form exists precisely so that split is possible.
- How do you prerender specific catch-all paths with generateStaticParams?Return the segment value as an array, matching the runtime shape: `[{ slug: ['api','auth'] }, { slug: ['guides','setup'] }]`. A string such as `'api/auth'` is not two segments and will not resolve to that URL. For an optional catch-all, the root path is prerendered by returning `{ slug: [] }` alongside the others.
- Does a catch-all route shadow a more specific sibling route in the same folder?No. Next matches most-specific-first: a literal folder beats a single dynamic segment, which beats a catch-all, which beats an optional catch-all. So `app/shop/cart/page.tsx` keeps serving `/shop/cart` even with a catch-all next to it — you do not have to special-case those paths inside the catch-all.
saying these in an interview costs you the question
- Says [[...slug]] gives an empty array at the root
- Thinks [...slug] also matches the bare parent URL
- Expects a single joined string instead of an array
- Believes a catch-all shadows literal sibling routes
- Returns 'a/b' from generateStaticParams for a catch-all