Skip to content
intermediate Phase 4 · File-Based and Advanced Routing

Catch-All and Optional Segments

Match one or many remaining path pieces with one file.

40m
0 problems
Topic Progress 0%

Matching the Rest of the Path: What [...slug] Captures and What It Refuses

A docs site has pages at /docs, /docs/getting-started, and /docs/api/v2/auth/tokens. Modelling that with app/docs/[a]/[b]/[c]/page.tsx means guessing the maximum depth in advance, and the day a fifth level appears you are adding folders. The catch-all folder matches one or more remaining segments in a single position and hands them over as an array:

app/docs/[...slug]/page.tsx

/docs                          -> does NOT match (zero segments)
/docs/getting-started          -> slug = ['getting-started']
/docs/api/v2/auth              -> slug = ['api', 'v2', 'auth']

The required form — square brackets around three dots — demands at least one segment. /docs is a different route and needs its own app/docs/page.tsx; put one there and both work side by side, with the literal folder catching the bare path and the catch-all catching everything deeper. That split is the normal arrangement for a docs root.

The value is always an array of strings, even for a single segment. This is where first attempts break: slug.toLowerCase() fails on ['getting-started'], slug.length reports 1 for /docs/a/b/c, and slug[0] silently drops the rest of the path. Rebuild the path with slug.join('/') when you need it back as a string — round-tripping ['api','v2'] to 'api/v2' is lossless because segments never contain / after the router splits them.

// app/docs/[...slug]/page.tsx
export default async function DocsPage(props: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await props.params;
  const doc = await loadDoc(slug.join('/'));   // '' never happens here
  if (!doc) notFound();
  return <DocViewer doc={doc} trail={slug} />;
}

Note what the catch-all cannot do: it does not match the parent path, it does not match query strings, and it does not match a different prefix. app/docs/[...slug] is blind to /guides/anything; a second catch-all under guides/ is a separate route with its own array. Two dynamic folders at the same depth — app/docs/[...slug] and app/docs/[...path] — are equally specific, so the build warns about an ambiguous route, exactly as it does for [id] versus [slug].

Depth also matters for rendering. A catch-all URL can be arbitrarily long, so a page that renders a sidebar tree for every path is doing O(depth) work per request with no way to prerender the unknown ones. The usual answer pairs generateStaticParams for the hot paths (a return of { slug: ['getting-started'] } objects) with on-demand rendering for the tail. Note that each returned object's key must be an array, because that is the shape the page will receive — returning { slug: 'getting-started' } (a string) prerenders the wrong thing or fails to match at all.

Navigation into a catch-all is just string building. href={'/docs/' + slug.join('/')} is the pattern for both <Link> and router.push, and building it from your data (not from user input) keeps you clear of the protocol-relative trap where an href starting with // is treated as a full URL by the browser. Prefetching works as it does for any dynamic route: the loading shell goes out ahead of the click.

Compare the three bracket forms before you commit — the difference is a URL contract, not a style:

Folder /docs /docs/a/b params
[slug] no no (two segments) string
[...slug] no yes string[], never empty
[[...slug]] yes yes string[] or undefined

The middle column is the one that decides most reviews. Up next: what that optional form buys you, and why Next refuses to let it share a parent with a plain page.tsx.

The Empty Remainder: When [[...slug]] Must Also Match Its Parent Path

The optional catch-all adds one capability: matching an empty remainder. app/docs/[[...slug]]/page.tsx serves /docs, /docs/a, and /docs/a/b/c from one file, and the param you receive changes shape accordingly.

Request params.slug
/docs undefined (some versions: [])
/docs/getting-started ['getting-started']
/docs/api/v2 ['api', 'v2']

That undefined at the bare path is a genuine hazard. Code that does slug.join('/') without a guard throws Cannot read properties of undefined, so the idiom is to normalize immediately:

const { slug = [] } = await props.params;   // default covers the bare path
document = await loadDoc(slug.join('/'));   // '' -> the docs index

With that default in place, the index page and the deep pages share one code path — '' naturally selects your table of contents.

The ambiguity rule is the exam question here. If app/docs/page.tsx and app/docs/[[...slug]]/page.tsx both exist, /docs could be served by either, and the router refuses: during next build you get an error telling you the paths collide (in development, a warning naming both files). You must choose one home for the parent route. Three valid resolutions, in the order most teams pick them:

  1. Delete docs/page.tsx and let the optional catch-all own /docs — one file, one source of truth.
  2. Switch to [...slug] and keep docs/page.tsx — required catch-all never claims the parent.
  3. Keep both folders but move one behind a route group so their paths differ.

Option 2 is usually the better engineering call when the index page is a different animal (a landing page with marketing content) than the deep pages (a renderer). Option 1 wins when /docs is just "depth zero" of the same renderer. What you must not do is leave the collision for the build to discover on CI.

A second subtlety: the optional catch-all's undefined value interacts with searchParams and static generation. If you prerender with generateStaticParams returning [] (the bare path) plus a list of deep paths, each return value's slug key must be an array — { slug: [] } for the parent, { slug: ['api'] } otherwise. Returning undefined from generateStaticParams does not register the bare path.

Finally, remember what the dots mean syntactically. Single dots in a folder ([...slug]) make the segment required; double dots ([[...slug]]) make it optional. The same convention governs route groups and private folders — (group) and _folder — and the next topic, Route Groups and Private Folders, is about folders that deliberately produce no URL at all. The unifying idea is that bracket punctuation, not folder position, carries the routing meaning.

The optional form also determines where its sibling special files live. A loading.tsx or error.tsx placed inside app/docs/[[...slug]]/ covers every depth including the bare parent, because they belong to the same segment; a not-found.tsx there catches notFound() calls thrown while rendering any of those paths. Put them beside the folder instead (in app/docs/) and they cover the whole subtree differently — the page.tsx under docs/ and the catch-all folder are siblings, not the same segment, so their boundaries do not overlap. Getting this wrong is invisible until a slow request shows a blank screen where you expected a skeleton.

Migration context helps explain why the optional form exists at all: in the Pages Router, pages/docs/[[...slug]].tsx was the standard way to serve both the index and deep paths, and the App Router kept the identical notation so a [[...slug]].tsx file moves to app/docs/[[...slug]]/page.tsx with no mental translation. What changed is the param shape — Pages Router gave a string[] with a separate isDynamicRoute check, while the App Router gives string[] | undefined and asks you to default it.

One File, a Whole Docs Tree: Breadcrumbs, Tree Lookups, and 404s by Depth

A documentation tree is the canonical catch-all use case, and building it well exposes three decisions the folder name does not make for you: how the path becomes data, how you render navigation for where the reader is, and what happens when the path is well-formed but the document is gone.

Map the array to a tree lookup. The array is already hierarchical — ['api', 'v2', 'auth'] is a node three levels down — so resolve it depth-first rather than string-concatenating into a query:

async function resolveDoc(path: string[]) {
  let node = DOC_ROOT;
  for (const seg of path) {
    node = node.children?.[seg];
    if (!node) return null;          // dead branch -> notFound()
  }
  return node;                       // [] -> the root, handled by index route
}

Returning null at the first missing child gives you a precise 404 for /docs/api/nonexistent, which you convert with notFound() — the router then renders the nearest not-found.tsx. Failing the whole lookup because one segment is wrong beats rendering a half-matched page with a broken breadcrumb.

Breadcrumbs are free from the same array. Because slug is ordered, the trail builds in one pass, and each prefix is a link:

{slug.map((seg, i) => (
  <Link key={i} href={'/docs/' + slug.slice(0, i + 1).join('/')}>
    {titles[seg] ?? seg}
  </Link>
))}

No extra fetch, no server round trip per crumb — just slice the array you already have. The same slice trick powers a sidebar that highlights the active branch: walk the array once, mark nodes on the path as open.

Static vs on-demand. A docs tree is usually known at build time, so generateStaticParams is a natural fit: return every node's slug array and the popular pages ship as static HTML, while a page someone deep-links to on day one renders on demand (or 404s if you set dynamicParams = false). The trade-off table is the same as for [id], with one addition — a catch-all tree can be large, and returning thousands of paths inflates build time. Prerender by depth: return the first two levels statically, let the long tail render on request.

Failure modes worth naming in an interview. Forgetting that /docs needs either an index page or an optional catch-all. Assuming slug is a string and rendering getting-started/api as a single label. Letting a trailing slash (/docs/api/) produce a different array than the clean URL — Next normalizes it, but your internal Link hrefs should too, or you will generate links that redirect once before landing. And rendering the whole sidebar inside the catch-all page means every request pays for it; putting the nav in a layout next to the catch-all folder means it renders once per navigation and the catch-all page only supplies the article body.

That last point is the architectural punchline: the catch-all owns the article, a layout owns the chrome, and the array is the only data structure either of them needs.

Metadata is where the array pays a second dividend. generateMetadata({ params }) runs for the same segment, so the document title and OpenGraph description come from the node you already resolved — one lookup, two consumers — and a not-found from loadDoc during metadata generation produces a proper 404 document rather than a title of undefined. Shared search indexing also reads the path: because your URL mirrors the tree, sitemap.ts can enumerate the same nodes generateStaticParams returns, and the two stay in sync from one source of truth.

Finally, decide deliberately what happens at depth zero. If /docs renders a landing page with a big search box while deep paths render articles, an optional catch-all (with the slug = [] default) lets one file branch on slug.length === 0; if the index is genuinely a different page, give it its own page.tsx and keep the catch-all required. Either choice is defensible — silently rendering an article-shaped page for the index, or a 404, is not.

Quiz

1. app/docs/[...slug]/page.tsx exists. Which requests match it?

Question 1 options

2. A request arrives for /docs/api/v2/auth. What is params.slug in [...slug]/page.tsx?

Question 2 options

3. You keep both app/docs/page.tsx and app/docs/[[...slug]]/page.tsx. What happens?

Question 3 options

4. A docs URL is well-formed but 'api/nonexistent' has no such document. What is the correct response?

Question 4 options

Flashcards

Question

Compare [slug], [...slug], and [[...slug]]: what matches, and what shape is params?

Answer

[slug] matches exactly one segment -> string. [...slug] matches one or more -> non-empty string[]. [[...slug]] matches zero or more (including the parent path) -> string[] or undefined/[] at the bare path. Rebuild with slug.join('/'); default the empty case with `const { slug = [] } = await params`.

Question

Why does Next error when docs/page.tsx and docs/[[...slug]]/page.tsx coexist?

Answer

The optional catch-all also matches the bare parent path, so /docs has two possible handlers — a true ambiguity, reported as a build error (dev warning) naming both files. Fix: delete the index page, use required [...slug] instead, or relocate one folder so the emitted paths differ.

Question

How do you build breadcrumbs and an active sidebar from a catch-all param?

Answer

slug is an ordered array of segments, so breadcrumb i is href='/docs/' + slug.slice(0, i+1).join('/') with the label from a title map; mark sidebar nodes open by walking the same array once. No extra fetch — the URL already carries the hierarchy.

Question

How should a large docs tree be prerendered, and what does generateStaticParams return?

Answer

Return an array of { slug: string[] } objects — one per known node, e.g. { slug: ['api','v2'] }; use { slug: [] } for the bare path with an optional catch-all. Prerender shallow levels for speed and let the long tail render on demand (or 404 when dynamicParams=false).

Question

Where should the docs sidebar live relative to a catch-all page?

Answer

In a layout beside the [...slug] folder, not inside the page. The layout renders once per navigation and owns the chrome; the catch-all page renders only the article body. Otherwise every deep-page request pays to rebuild navigation it shares with every other page.

Revision Notes

Key Takeaways

  • 1. [...slug] matches one or more remaining segments and yields a non-empty string[]; the bare parent path still needs its own page or an optional catch-all
  • 2. [[...slug]] adds the empty remainder, so params.slug can be undefined — default it to [] before touching it
  • 3. page.tsx beside [[...slug]]/ is an ambiguity on the parent path and fails the build until you pick one owner
  • 4. The array already encodes hierarchy: breadcrumbs, active nav, and tree lookup all come from one pass with no extra fetch
  • 5. Static prefixes are not in the array, values are decoded strings, and slug.join('/') round-trips them losslessly

Interview Tips

  • • "Required vs optional catch-all?" — one answers with the matching rule and the param shape (never-empty array vs possibly-undefined), then the ambiguity consequence for the parent path
  • • "How would you serve a docs site?" — one catch-all file + a layout for the sidebar, tree walk by segment, notFound() on a dead branch, generateStaticParams for the hot paths
  • • "Why is my /docs route failing the build?" — the classic page.tsx + [[...slug]] collision; explain that the optional form claims the parent and how you'd choose between the three fixes
  • • "Where does the breadcrumb data come from?" — the URL itself: ordered slug array sliced per crumb; if you need titles, a map keyed by segment, not a DB query per crumb

Cheat Sheet

Catch-All and Optional Segments

The three bracket forms:

Folder /docs /docs/a/b params.x
[x] no no string
[...x] no yes string[] (never empty)
[[...x]] yes yes string[] | undefined

Single dots = required, double dots = optional. Static prefix segments never appear in the array.

Canonical page:

export default async function DocsPage(props: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug = [] } = await props.params;
  const node = await resolveDoc(slug);   // walk children by segment
  if (!node) notFound();
  return <DocViewer doc={node} trail={slug} />;
}

Ambiguity: page.tsx + [[...slug]] in the same folder collide on the parent path -> build error. Resolve by deleting one, switching to [...slug], or moving a folder behind a route group.

Breadcrumbs from the array:

slug.slice(0, i + 1).join('/')   // href for crumb i

Prerender: generateStaticParams returns [{ slug: ['api'] }, ...] — arrays, never strings; { slug: [] } covers the bare path with the optional form. Shallow levels static, long tail on demand.

Layout, not page: put sidebar/nav in a sibling layout so the catch-all renders only the article body.

Continue Learning