Catch-All and Splat Routes Explained
After reading this you will be able to use wildcard segments deliberately — for documentation trees, file browsers and not-found handling — without letting them absorb requests that belonged to a more specific route.
← Back to Dynamic Route Segments
Prerequisites
Core Concept
A dynamic segment such as :id matches exactly one path segment. A catch-all — also called a splat, a rest parameter, or a wildcard depending on the router — matches every remaining segment, including the slashes between them. Where /docs/:page matches /docs/intro and fails on /docs/api/auth/tokens, /docs/*path matches both and binds path to "api/auth/tokens".
That single difference is the reason to reach for one: some hierarchies have no fixed depth. A documentation tree, a file browser, a nested taxonomy, or a CMS whose editors can nest pages arbitrarily cannot be expressed as :a/:b/:c without guessing a maximum. The catch-all lets the route table stop caring about depth and hand the whole remainder to code that does.
The syntax varies but the semantics do not: React Router writes * and reads params["*"]; Vue Router uses :path(.*)*; Next.js file-system routing uses [...slug] (and [[...slug]] for the optional form); SvelteKit uses [...rest]; Express uses *. All bind the remainder to a single value, and all rank it as the least specific segment type.
That last property is the one that matters most in practice. Because a catch-all is the lowest rank, a correctly ordered table always tries every real route first and only falls through when nothing else claimed the path. Get the ordering wrong — or use a router that matches in registration order — and the wildcard becomes a black hole that quietly answers requests intended for routes registered after it.
Implementation
Three uses account for nearly all legitimate catch-alls: a content tree, a static file browser, and the not-found route. All three follow the same shape — capture the remainder, validate it, then resolve it against a real hierarchy.
// TypeScript 5.x — a documentation tree served by one catch-all route
interface DocNode {
title: string;
children?: Record<string, DocNode>;
}
const docs: Record<string, DocNode> = {
api: {
title: "API",
children: { auth: { title: "Auth", children: { tokens: { title: "Tokens" } } } },
},
};
/** Resolve "api/auth/tokens" against the tree, or null if any level is missing. */
export function resolveDocPath(splat: string): DocNode | null {
// Normalise first: strip leading/trailing slashes and collapse duplicates.
const segments = splat.split("/").filter(Boolean);
if (segments.length === 0) return null;
let level: Record<string, DocNode> | undefined = docs;
let node: DocNode | undefined;
for (const segment of segments) {
// Reject traversal and encoded traversal before touching the tree.
if (segment === "." || segment === "..") return null;
node = level?.[segment];
if (!node) return null;
level = node.children;
}
return node ?? null;
}
The route definition itself needs the wildcard to be last, and needs the validation to run before anything is fetched:
// TypeScript 5.x — the catch-all sits at the bottom of its own subtree
export const docRoutes = [
// Specific routes first. These must be reachable, so they cannot come after
// the wildcard in a registration-order router.
{ pattern: "/docs", handler: "docs-index" },
{ pattern: "/docs/search", handler: "docs-search" },
{ pattern: "/docs/*path", handler: "docs-page" },
];
export function handleDocRoute(splat: string) {
const decoded = decodeURIComponent(splat);
if (decoded.length > 512) return { view: "not-found" as const }; // bound the input
const node = resolveDocPath(decoded);
return node ? { view: "docs-page" as const, node } : { view: "not-found" as const };
}
And the application-level not-found route, which is simply a catch-all at the root with the lowest possible specificity:
// TypeScript 5.x — the terminal fallback
export const appRoutes = [
...docRoutes,
{ pattern: "/users/:id", handler: "user-detail" },
// Bare wildcard at the root: matched only when nothing else claimed the path.
{ pattern: "/*rest", handler: "not-found" },
];
Syntax across routers
The same concept wears five names, and the differences that matter are in what happens at the parent path and how the captured value is read.
| Router | Syntax | Reading the value | Matches the parent path? |
|---|---|---|---|
| React Router 6 | path: "docs/*" |
params["*"] |
No — declare an index route |
| Vue Router 4 | /docs/:path(.*)* |
route.params.path as an array |
Yes, with an empty array |
| Next.js App Router | app/docs/[...slug] |
params.slug as an array |
No — use [[...slug]] |
| SvelteKit | src/routes/docs/[...rest] |
params.rest as a string |
Yes, with an empty string |
| Angular Router | path: "docs/**" |
Read from the UrlSegment[] |
Yes |
Two practical consequences follow. First, whether the captured value is a string or an array is a real portability hazard: code written against SvelteKit’s string breaks against Next.js’s array in a way TypeScript will catch only if the params are typed at the boundary. Second, the “matches the parent path” column is the source of the single most common catch-all bug — a section index that 404s in one framework and works in another with identical-looking route files.
Verification
// TypeScript 5.x — the assertions that catch a swallowing wildcard
import { match } from "./router";
console.assert(match("/docs/search")?.handler === "docs-search"); // NOT docs-page
console.assert(match("/docs/api/auth/tokens")?.handler === "docs-page");
console.assert(match("/docs/api/auth/tokens")?.params.path === "api/auth/tokens");
console.assert(match("/users/42")?.handler === "user-detail"); // wildcard did not win
console.assert(match("/nothing/here")?.handler === "not-found"); // wildcard is the fallback
console.assert(resolveDocPath("api/../admin") === null); // traversal rejected
console.assert(resolveDocPath("") === null); // empty remainder
The end-to-end signal is easier to spot than it sounds: if any specific route inside a wildcard’s subtree renders the wildcard’s view, the ordering is wrong. Add one Playwright assertion per specific route that lives under a catch-all — they are cheap and they fail loudly the moment someone reorders the table.
Gotchas
- A catch-all must be the final segment. Anything registered after it inside the same pattern is unreachable; good routers throw at registration time, and the ones that do not will simply ignore it.
- The captured value contains slashes, so it is a path, not a name. Never concatenate it into a filesystem path or a URL without validating each segment —
..in the middle is the classic traversal. - Percent-encoded slashes survive.
%2Finside a segment decodes to/after capture, so validate before decoding, then re-check after. - An empty remainder is a real case.
/docs/may bind the splat to""; decide whether that means the index page or a not-found, and encode the decision rather than letting it depend on a truthiness check. - Optional catch-alls behave differently from required ones. Next.js’s
[[...slug]]also matches the parent path itself;[...slug]does not. Mixing them up produces a 404 on exactly one URL, usually the section index. - Do not use a catch-all to implement redirects. It matches everything and hides typos in real route patterns; keep a redirect table separate, as in router migration and interop.
FAQ
When is a catch-all the wrong tool? Whenever the depth is actually known. Two levels of nesting are better expressed as :section/:page, which gives you named parameters, per-level validation, and precedence that behaves predictably.
How do I get named segments out of a splat? Split it yourself after capture. The router deliberately does not, because it cannot know how many levels there will be — which is the same reason you chose a catch-all.
Does a catch-all hurt SEO? Not by itself, but it makes soft 404s very easy to create, because every malformed URL matches something. Pair it with a genuine 404 status for remainders that do not resolve.
Can I have two catch-alls in one table? Yes, at different levels — /docs/*path and a root /*rest coexist fine, because the more deeply nested one is more specific. Two at the same level is ambiguous and should be rejected.
What about matching file extensions? A catch-all captures guide.pdf as part of the remainder, extension included. If the extension changes the handling, split it off explicitly after capture rather than trying to encode it in the pattern.
Related
- Dynamic Route Segments — the single-segment parameters a catch-all generalises.
- Route Precedence and Specificity Rules — why the wildcard ranks last and what happens when it does not.
- Validating Route Parameters with TypeScript — the boundary check a captured remainder needs more than any other parameter type.