
How to use rest parameters for catch-all routes in Astro?
To create a catch-all route in Astro, use a rest parameter in the file name: three dots inside square brackets, such as src/pages/docs/[...path].astro. Unlike a normal [param], which matches exactly one URL segment, a rest parameter matches any number of segments, so one file can serve /docs, /docs/setup and /docs/reference/api/endpoints. In Astro.params, the value is a single string with the segments joined by slashes, like "reference/api/endpoints", or undefined for the top-level URL. In static builds you list the values in getStaticPaths(); with on-demand rendering, any matching URL reaches the page.
Rest parameters are what you reach for when content is nested to an unknown depth: documentation trees, CMS pages with parent-child structures, file browsers, or a fallback page for an entire section. In this article you'll learn the syntax and how values are passed, how to generate nested pages from a content collection, how to make the same file handle the section root, how to combine rest parameters with named ones, how catch-all routes behave on demand, and how they interact with other routes.
The Syntax
The difference between a named parameter and a rest parameter is three dots:
| File | Matches | Doesn't match |
|---|---|---|
docs/[slug].astro | /docs/setup | /docs, /docs/guides/setup |
docs/[...path].astro | /docs, /docs/setup, /docs/guides/setup | Anything outside /docs |
The name after the dots is up to you. [...path], [...slug] and [...id] are all common; pick the one that describes the value.
If dynamic routes are new to you, read how to create dynamic routes with getStaticPaths first. Rest parameters follow the same rules, with one extra capability.
A Static Catch-All Route
In the default static output, you still need getStaticPaths(). Each value can contain slashes:
---
// src/pages/sequences/[...path].astro
export function getStaticPaths() {
return [
{ params: { path: "one/two/three" } },
{ params: { path: "four" } },
{ params: { path: undefined } },
];
}
const { path } = Astro.params;
---
<p>path = {String(path)}</p>
The build generated three pages from that one file:
/sequences/one/two/three/index.html
/sequences/four/index.html
/sequences/index.html
And the pages rendered these values:
/sequences/ -> path = undefined
/sequences/one/two/three -> path = one/two/three
Two things to notice:
- The value is one string: Astro doesn't split it into an array. Call
path.split("/")if you need the individual segments. - undefined means the root: returning
{ path: undefined }makes the same file answer at/sequences. You don't need a separateindex.astro.
Nested Docs from a Content Collection
The most practical use for a rest parameter is a documentation section where pages live in nested folders. The glob() loader gives each entry an id that includes its folder path, which maps neatly onto a rest parameter.
Given this content:
src/content/docs/
index.md
guides/
setup.md
deploy.md
reference/
api/
endpoints.md
Define the collection:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const docs = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/docs" }),
schema: z.object({ title: z.string() }),
});
export const collections = { docs };
Then one page renders the whole tree:
---
// src/pages/docs/[...id].astro
import { getCollection, render } from "astro:content";
import type { GetStaticPaths } from "astro";
export const getStaticPaths = (async () => {
const docs = await getCollection("docs");
return docs.map((entry) => ({
params: { id: entry.id === "index" ? undefined : entry.id },
props: { entry },
}));
}) satisfies GetStaticPaths;
const { entry } = Astro.props;
const { Content } = await render(entry);
const crumbs = entry.id === "index" ? [] : entry.id.split("/");
---
<html lang="en">
<head>
<title>{entry.data.title}</title>
</head>
<body>
<nav aria-label="Breadcrumb">
<a href="/docs/">Docs</a>
{crumbs.map((part) => <span> / {part}</span>)}
</nav>
<h1>{entry.data.title}</h1>
<Content />
</body>
</html>
The build output:
/docs/guides/deploy/index.html
/docs/guides/setup/index.html
/docs/index.html
/docs/reference/api/endpoints/index.html
The index entry is mapped to undefined, so it renders at /docs/ rather than /docs/index/. The breadcrumb splits the id into segments, and for the deepest page it rendered:
<nav aria-label="Breadcrumb"><a href="/docs/">Docs</a><span> / reference</span><span> / api</span><span> / endpoints</span></nav>
Entry ids from glob() are generated from the file path, so reference/api/endpoints.md becomes reference/api/endpoints. That's what makes this pattern work without any extra slug logic. If you'd prefer a different URL than the file path, add a slug field to your frontmatter; the glob() loader uses it as the entry id. Collections are covered in detail in what content collections are in Astro.
Passing Different Data per Depth
Because props can carry anything, you can build pages of different kinds from one file. The Astro docs show a small store where the root, a category and a product all come from the same rest route:
---
// src/pages/[...slug].astro
export function getStaticPaths() {
const pages = [
{ slug: undefined, title: "Astro Store", text: "Welcome to the Astro store!" },
{ slug: "products", title: "Astro products", text: "We have lots of products for you" },
{ slug: "products/astro-handbook", title: "The ultimate Astro handbook", text: "If you want to learn Astro, you must read this book." },
];
return pages.map(({ slug, title, text }) => ({
params: { slug },
props: { title, text },
}));
}
const { title, text } = Astro.props;
---
<html lang="en">
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<p>{text}</p>
</body>
</html>
A root-level [...slug].astro like this is powerful, but it claims every URL that no other route handles. Use it deliberately, for example when a headless CMS owns the entire page tree.
Combining Rest and Named Parameters
Rest parameters can sit alongside named ones in the same path. A GitHub-style file viewer is the classic example:
src/pages/[org]/[repo]/tree/[branch]/[...file].astro
I built this as an on-demand page so any URL would reach it:
---
// src/pages/[org]/[repo]/tree/[branch]/[...file].astro
export const prerender = false;
const { org, repo, branch, file } = Astro.params;
---
<pre>{JSON.stringify({ org, repo, branch, file }, null, 2)}</pre>
Requesting /withastro/astro/tree/main/docs/public/favicon.svg from the built Node server returned:
{
"org": "withastro",
"repo": "astro",
"branch": "main",
"file": "docs/public/favicon.svg"
}
And /withastro/astro/tree/main, with nothing after the branch, left file as undefined, so JSON.stringify dropped it:
{
"org": "withastro",
"repo": "astro",
"branch": "main"
}
Each named parameter takes exactly one segment, and the rest parameter takes whatever is left, including nothing.
Catch-All Routes Rendered on Demand
With an adapter configured, a rest route that opts out of prerendering doesn't need getStaticPaths(). Every matching URL reaches the page, which means you must handle paths that don't correspond to real content:
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
});
---
// src/pages/help/[...path].astro
export const prerender = false;
const pages: Record<string, { title: string; body: string }> = {
"": { title: "Help", body: "Start here." },
"billing": { title: "Billing", body: "Invoices and payments." },
"billing/refunds": { title: "Refunds", body: "How refunds work." },
};
const key = Astro.params.path ?? "";
const page = pages[key];
if (!page) {
return new Response(null, { status: 404 });
}
---
<html lang="en">
<head>
<title>{page.title}</title>
</head>
<body>
<h1>{page.title}</h1>
<p>{page.body}</p>
</body>
</html>
Returning an empty Response with a 404 status tells Astro the page wasn't found, and Astro answers with your custom 404.astro page at the requested URL. Testing the built Node server:
/help -> 200 <h1>Help</h1>
/help/billing/refunds -> 200 <h1>Refunds</h1>
/help/nope/deep -> 404 <h1>Not found page</h1>
There are a few ways to send a 404 from Astro, with some differences in production, covered in how to create a custom 404 page in Astro.
One rule applies only to on-demand routes: the docs state that only one rest parameter may be used in an on-demand file path. src/pages/[locale]/[...slug].astro is fine; src/pages/[...locale]/[...slug].astro is not. Two greedy parameters next to each other are ambiguous anyway, because there's no way to know where one ends and the next begins.
How Rest Routes Interact with Other Routes
A catch-all is the least specific kind of route, so Astro picks almost anything else first. Within the same folder:
- Static routes win:
docs/changelog.astroserves/docs/changelogeven thoughdocs/[...path].astrocould match it. - Named parameters beat rest parameters:
docs/[slug].astrotakes single-segment URLs like/docs/setup, leaving deeper ones to[...path]. - Deeper folders beat shallower ones: routes under
/docs/take precedence over a root[...slug].astro.
This is what lets you add a catch-all safely: specific pages keep working and the rest route only fills the gaps. The full rule list is in route priority in Astro.
Rest parameters also work in redirects, as long as both sides use the same parameter:
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
redirects: {
"/blog/[...slug]": "/articles/[...slug]",
},
});
See how to set up redirects in Astro for the details and limitations.
Tips for Catch-All Routes
- Return undefined for the root: avoid a separate
index.astrowhen the same template serves the section home. - Split when you need segments: the param is a string, so use
split("/")for breadcrumbs or lookups. - Avoid a root catch-all unless you need one: it captures every unknown URL, which can hide broken links behind a real-looking page.
- Always handle unknown paths on demand: return a 404 response instead of rendering an empty template.
- Don't decode by accident: params from
getStaticPaths()aren't decoded, and since Astro 6 route pathnames may be partially decoded during normalisation. UsedecodeURI()deliberately when you need it.
FAQ: Rest Parameters in Astro
It marks a rest parameter. A file like [...path].astro matches URLs with any number of segments at that position, including none, and passes them to the page as a single string.
No. Astro gives you one string with segments separated by slashes, such as guides/setup. Split it yourself if you need an array.
In getStaticPaths(), return an entry with the rest parameter set to undefined. For on-demand routes, the parameter is undefined when the URL is the folder itself.
Not in on-demand routes: the Astro docs allow only one rest parameter in the file path of an on-demand route. Combine one rest parameter with as many named parameters as you need instead.
No. Static routes and named parameters take priority over rest parameters, and deeper routes take priority over shallower ones, so a catch-all only handles URLs nothing else claims.
Check whether the path matches real content, and if not, return an empty Response with a 404 status. Astro then serves your custom 404 page with a 404 status at the requested URL.
Conclusion
A rest parameter turns one Astro page into a route for a whole branch of your site. Name the file [...path].astro, return slash-separated strings from getStaticPaths() for static builds, use undefined for the root, and split the value when you need individual segments. Combined with the glob() loader's path-based ids, it's the simplest way to publish a nested documentation tree.
On demand, catch-all routes become even more flexible, because any matching URL reaches your code. That flexibility comes with responsibility: look up the content, handle what doesn't exist with a proper 404, and lean on Astro's route priority so specific pages always win over the catch-all.


