Dynamic Routes
Routes with a variable part
Section titled “Routes with a variable part”You have a collection of 50 blog posts and want a page for each — but you’re not going to write 50 files. A dynamic route is one file whose name has a variable segment in square brackets, and it stands in for many URLs:
src/pages/blog/[slug].astro→ matches/blog/anythingsrc/pages/[category]/[id].astro→ two variablessrc/pages/docs/[...path].astro→ a rest param, matching/docs/a/b/c(any depth)
The value in the brackets arrives at runtime on Astro.params (Astro.params.slug). But for a static site, Astro also needs to know which slugs exist so it can build a file for each — that’s what getStaticPaths() is for.
Rest params in practice
Section titled “Rest params in practice”A rest param ([...path]) collects everything after it into one string, and — unlike a normal bracket param — it can also match zero segments, in which case Astro.params.path is undefined:
---export async function getStaticPaths() { return [ { params: { path: 'guide/intro' } }, { params: { path: 'guide/advanced/setup' } }, { params: { path: undefined } }, // matches /docs itself ];}const { path } = Astro.params;---<p>path: {path ?? '(root)'}</p>Proven in the probe — one file builds routes at every depth, including the bare /docs root:
generating static routes ├─ /docs/guide/intro/index.html ├─ /docs/guide/advanced/setup/index.html ├─ /docs/index.html$ cat dist/docs/index.html | grep -o '<p>.*</p>'<p>path: (root)</p>getStaticPaths(): one page per entry
Section titled “getStaticPaths(): one page per entry”In a static build, a dynamic route file exports getStaticPaths(), which returns the list of pages to generate. Each item is { params, props } — params fills the bracketed segment, props passes data to the page:
---import { getCollection, render } from 'astro:content';
export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { slug: post.id }, // fills [slug] — one page per post props: { post }, // hand the whole entry to the page }));}
const { post } = Astro.props;const { Content } = await render(post);---<article> <h1>{post.data.title}</h1> <time>{post.data.pubDate.toDateString()}</time> <Content /></article>At build time Astro calls getStaticPaths(), sees (say) 50 entries, and writes 50 HTML files — /blog/hello-world, /blog/second-post, and so on. The params key must match the bracket name (slug here); the props are your escape hatch to pass the already-loaded entry so the page body needn’t re-query.
flowchart LR
coll["getCollection('blog') — 50 entries"] --> map["map each to params + props"]
map --> paths["getStaticPaths returns 50 paths"]
paths --> build["build writes 50 HTML files"]Pagination with paginate()
Section titled “Pagination with paginate()”For a long list you want numbered pages — /blog/1, /blog/2. getStaticPaths receives a paginate helper that slices a list into pages and generates the routes for you. Name the route file with the page param, usually src/pages/blog/[page].astro:
---import type { GetStaticPathsOptions } from 'astro';import { getCollection } from 'astro:content';
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) { const posts = await getCollection('blog'); return paginate(posts, { pageSize: 10 }); // 10 posts per page}
const { page } = Astro.props; // paginate injects a `page` prop---<ul> {page.data.map((post) => <li>{post.data.title}</li>)}</ul><nav> {page.url.prev && <a href={page.url.prev}>Previous</a>} <span>Page {page.currentPage} of {page.lastPage}</span> {page.url.next && <a href={page.url.next}>Next</a>}</nav>paginate hands each page a page prop with data (this page’s slice), currentPage, lastPage, and url.prev/url.next — everything you need for the list and the prev/next links.
Proven in the probe — four posts at pageSize: 2 build exactly two numbered pages, and the <nav> renders the real prev/next links paginate computed:
<nav><span>Page 1 of 2</span><a href="/blog/2">Next</a></nav><!-- real build output: dist/blog/2/index.html --><nav><a href="/blog/1">Previous</a><span>Page 2 of 2</span></nav>When getStaticPaths() is missing or invalid
Section titled “When getStaticPaths() is missing or invalid”Two build-blocking mistakes are common enough to know by their exact message. First, a static dynamic route with no getStaticPaths at all — Astro doesn’t guess; it fails the build with a named error:
[GetStaticPathsRequired] `getStaticPaths()` function is required for dynamic routes.Make sure that you `export` a `getStaticPaths()` function from your dynamic route. Hint: If you meant for this route to be server-rendered, set `export const prerender = false;` in the page.Second, a getStaticPaths() that runs but returns something malformed — here, an object missing the required params key:
export async function getStaticPaths() { return [{ id: 'one' }]; // missing `params` — invalid}[GetStaticPathsExpectedParams] Missing or empty required `params` property on `getStaticPaths()` route.Both are proven in the probe on Astro 7.3.5, and both name the exact contract getStaticPaths() must satisfy — the first error’s own hint is often the fix: if the route was actually meant to be on-demand, add prerender = false and drop getStaticPaths entirely rather than trying to satisfy it with a fake list.
Static params vs on-demand params
Section titled “Static params vs on-demand params”There are two ways a dynamic route learns its params, and they mirror the two rendering modes:
- Static: you enumerate every param ahead of time with
getStaticPaths(), and Astro builds a file per path. Fast, cacheable, but the set of pages is fixed at build. - On-demand: set
export const prerender = falseand dropgetStaticPaths()— instead readAstro.paramslive and query on the fly. This handles URLs you can’t know at build time (a user id, a search term):
---// src/pages/blog/[slug].astro — on-demand versionexport const prerender = false;import { getEntry, render } from 'astro:content';
const { slug } = Astro.params;if (!slug) return Astro.redirect('/404');const post = await getEntry('blog', slug);if (!post) return Astro.redirect('/404');
const { Content } = await render(post);---<h1>{post.data.title}</h1><Content />Rule of thumb: a known, finite set of pages (all your blog posts) → getStaticPaths() and stay static. An open-ended set (any user profile, arbitrary search) → prerender = false and read Astro.params per request.
Mental Model
Section titled “Mental Model”getStaticPaths() isn’t a hook Astro calls incidentally — on a static build it’s the entire contract a dynamic route file must satisfy, checked before a single line of the component script runs. That’s why a missing one fails with a named, specific error rather than an empty page: Astro can’t build “some” pages for a route it doesn’t understand the shape of, so it refuses to build any. Switching to prerender = false doesn’t relax that contract — it replaces it with a different one (read Astro.params per request), which is why the two approaches are mutually exclusive rather than layered.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
getStaticPaths() (static) |
Every page is a plain file — fast, CDN-cacheable, no server needed at request time | The full set of pages must be knowable at build time; adding a page means rebuilding |
paginate() |
Numbered pages, prev/next links, and the slicing logic generated for you | Ties the route’s filename to the page param ([page].astro) and the data source to one paginate() call |
[...path] rest param |
One file handles any depth, including the bare parent route (path is undefined) |
Every consumer of Astro.params.path must handle the undefined case explicitly |
prerender = false + Astro.params |
Handles URLs that don’t exist yet at build time | Needs an adapter; every request now costs a server round-trip instead of a CDN hit |
Common Misconceptions
Section titled “Common Misconceptions”- “A dynamic route without getStaticPaths just builds an empty page.” It doesn’t build at all — Astro fails the whole build with
GetStaticPathsRequired, naming the exact file. This is deliberate: a static site cannot have a route with an undefined set of pages. - “getStaticPaths can return anything as long as the page reads Astro.params correctly.” The return shape is validated too — a returned object missing
paramsfails withGetStaticPathsExpectedParams, independent of whether the page script would have handled it gracefully. - “A rest param
[...path]always has a value.” It matches zero segments too (the route’s own parent path), andAstro.params.pathisundefinedin that case — code that assumes a string and calls.split('/')on it directly will crash on exactly that route. - “Dropping getStaticPaths and adding prerender = false is enough to fix a ‘GetStaticPathsRequired’ error.” It’s not a patch, it’s a different rendering mode entirely — it also requires an adapter configured for on-demand rendering, which a purely static project doesn’t have by default.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet defines a dynamic route, check for these before trusting it:
- Does a route under
[slug].astro/[id].astro(with the project’s default static output) exportgetStaticPaths()at all? Its absence isn’t a lint nit — it’s a build-breaking error with no fallback. - Does every object
getStaticPaths()returns include aparamskey, and does that key’s name match the file’s own bracket name? - Does code reading a rest param’s value (
Astro.params.path) assume it’s always a non-empty string, without handling the route’s own parent path where it’sundefined? - If a route sets
prerender = false, did the same file also keep a stalegetStaticPaths()around from when it was static? It’s simply ignored once the page is on-demand — worth deleting, not leaving as dead code that looks load-bearing. - Does
paginate(list, { pageSize })get called with the full, unfiltered list, or did an earlier.filter()/.slice()already narrow it in a way that silently changes the page count from what the reader expects?
🐛 Spot the bug
---import { getCollection, render } from 'astro:content';
const posts = await getCollection('blog');const post = posts.find((p) => p.id === Astro.params.slug);if (!post) throw new Error('Post not found');
const { Content } = await render(post);---<h1>{post.data.title}</h1><Content />Show the answer
This file has no getStaticPaths() export at all, but the project’s default output is static and the route has a bracketed segment ([slug]). Astro validates that contract before running any component script — it doesn’t try the file and fail gracefully; it refuses to build. Proven in the probe on Astro 7.3.5: the build stops immediately with [GetStaticPathsRequired] getStaticPaths() function is required for dynamic routes, naming this exact file, and its own hint says exactly what’s missing: either add a getStaticPaths() that returns { params: { slug } } for every post, or — if the intent was genuinely per-request lookup — add export const prerender = false; so the route becomes on-demand and getStaticPaths is no longer needed at all.