Lesson 77 of 81
A docs site with an arbitrary-depth folder tree, or a shop with nested categories, can't have one file per URL depth. Catch-all segments collapse every depth into one file and one array param.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A single dynamic segment ([slug]) captures exactly one URL piece. A catch-all segment ([...slug]) captures one or more remaining pieces at once, as an array — app/docs/[...slug]/page.tsx matches /docs/guide, /docs/guide/install, and /docs/guide/install/windows from one file, handing you { slug: ["guide"] }, { slug: ["guide", "install"] }, and { slug: ["guide", "install", "windows"] } respectively. It does not match bare /docs — a catch-all still requires at least one segment to capture.
The optional variant ([[...slug]], with the extra pair of brackets) removes that last restriction: it matches everything the plain catch-all matches, plus the bare route with nothing after it, giving { slug: undefined } in that case. This is genuinely the only structural difference between the two — "optional" means "and also zero segments," nothing more.
The real reason to reach for either: content whose depth you don't control ahead of time. A CMS-backed docs tree, a nested product-category browser, a wiki — anything where the shape of the URL tree is determined by content that changes independently of your code — can't be modeled as a fixed number of [param]/[param]/[param] files, because you don't know how many levels deep any given page will sit. One catch-all file handles every depth the content ever grows into, with no code change required when it does.
// app/docs/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import { getDocBySlugPath } from "@/lib/cms";
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
const path = slug ?? []; // undefined at the bare /docs route
const doc = await getDocBySlugPath(path);
if (!doc) notFound(); // a path the CMS tree doesn't actually contain
return <DocBody doc={doc} />;
}
export async function generateStaticParams() {
const allPaths = await getAllDocPaths(); // e.g. [["guide"], ["guide", "install"]]
return allPaths.map((slug) => ({ slug }));
}
Using the optional catch-all here means /docs itself (the landing page for the whole section) and every nested doc page resolve through this one file — without it, /docs would need a separate sibling page.tsx, duplicating the data-fetching logic instead of sharing it. generateStaticParams returns an array of arrays, one per real path depth the CMS actually has content for; each inner array becomes one prerendered route, regardless of how many segments long it is. notFound() still matters here precisely because dynamicParams defaults to true — without an explicit check, a URL with a plausible-looking but nonexistent path would still attempt to render instead of 404ing.
/shop alone 404s against app/shop/[...slug]/page.tsx no matter what; only [[...slug]] covers that case.undefined for the optional, zero-segment case), never a string — even when a URL happens to only have one segment, slug is ["guide"], not "guide". Code that assumes a string and calls .toUpperCase() on it directly will throw.generateStaticParams for a catch-all returns arrays of arrays, not an array of strings. Forgetting the extra nesting level here is the single most common mistake — { slug: "guide" } silently fails to prerender the way { slug: ["guide"] } does.dynamicParams defaulting to true means an unrecognized nested path still renders through the component instead of 404ing automatically — calling notFound() yourself when the data lookup comes back empty is what actually produces the 404."Design the routing for a docs site where content authors can nest pages arbitrarily deep, with no code deploy needed when they add a new level" is a question that tests whether a candidate reaches for catch-all segments at all, versus trying to hardcode a fixed number of [param] levels — and whether they remember the optional variant is what's needed if the section's own landing page should share the same file and data-fetching path as everything nested beneath it.