How to build dynamic post routes with getStaticPaths in Astro
Every blog post on this site lives at its own URL: /my-first-post, /another-post, and so on. But I did not create a separate file for each one. There is a single file, [slug].astro, that generates all of them at build time. The piece of magic that makes this work is a function called getStaticPaths. Let me show you exactly how it works.
What “dynamic route” and “static” mean here
A dynamic route is a file whose name contains a variable in square brackets, like [slug].astro. The slug part is a placeholder — it can match many different URLs.
Astro is a static site generator (SSG): instead of rendering pages on a server when a visitor asks for them, it renders every page ahead of time into plain HTML files during astro build. That is fast and cheap to host. But if the route is dynamic, Astro needs to know which actual URLs to generate. That is the whole job of getStaticPaths: it hands Astro the full list of pages to build.
The real route file
Here is the entire [slug].astro for this blog:
// blog/src/pages/[slug].astro
---
import type { GetStaticPaths } from "astro"
import { getPublishedPosts } from "@/utils/posts"
import PostLayout from "@/layouts/PostLayout.astro"
export const getStaticPaths = (async () => {
const posts = await getPublishedPosts()
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}))
}) satisfies GetStaticPaths
const { post } = Astro.props
---
<PostLayout post={post} />That is the complete file. Small, but a lot is happening. Let me break it down.
Step 1: get the data
const posts = await getPublishedPosts()getPublishedPosts lives in blog/src/utils/posts.ts and reads all my Markdown/MDX posts from Astro’s typed content collection, hides drafts in production, and sorts them newest-first:
// blog/src/utils/posts.ts
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection("posts", ({ data }) =>
isProd ? data.draft !== true : true,
)
return posts.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
)
}So posts is an array of post objects, each with an id (the slug) and a data object holding the frontmatter.
Step 2: map each post to a path
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}))getStaticPaths must return an array of path objects. Each object has two keys, and the distinction between them is the single most important thing to understand:
paramsfills in the bracketed part of the filename. Because the file is[slug].astro, the key here must beslug. Settingslug: post.idtells Astro “build a page at the URL matching this id.” So a post whose id ishello-worldbecomes the page/hello-world.propsis arbitrary data passed into the page for that specific build. Here I pass the wholepostobject so the page can render it without fetching anything again.
Here is the mental model:
flowchart LR
A[getPublishedPosts] --> B[array of posts]
B --> C{map each post}
C --> D["params: slug -> URL"]
C --> E["props: post -> page data"]
D --> F[static HTML page]
E --> FStep 3: use the props to render
Below the second --- fence, the page reads its props and renders:
const { post } = Astro.props
<PostLayout post={post} />Astro.props gives back exactly the props object I attached to this path in getStaticPaths. I pull out post and hand it to PostLayout, which is responsible for the actual visual output (title, formatted date, the post body, and so on).
What about satisfies GetStaticPaths?
export const getStaticPaths = (async () => {
// ...
}) satisfies GetStaticPathsGetStaticPaths is a TypeScript type Astro exports. The satisfies keyword checks that my function’s shape matches what Astro expects — the right return structure with params and props — without widening or erasing the specific types I return. In plain terms: if I typo param instead of params, TypeScript yells at me at build time instead of shipping a broken route. It is a cheap safety net.
Why params and props are separate
Beginners often ask why the slug is not just passed as a prop. The reason is that they answer different questions:
paramsanswers “what URL is this?” — it is part of the route itself and shows up inAstro.params.propsanswers “what data does this page need?” — it never appears in the URL.
You could rebuild the post from Astro.params.slug alone (look it up by id), but passing it through props means the data is already in hand — no second lookup, less code.
Trying it yourself
To reproduce this pattern in any Astro project:
- Create
src/pages/[slug].astro. - Export a
getStaticPathsthat returns an array of{ params: { slug }, props }. - Read
Astro.propsbelow the frontmatter fence and render. - Run
astro buildand watch one HTML file appear per item.
That is it — one file, and Astro fans it out into a whole section of your site. The same getStaticPaths pattern powers the per-tag pages in the blog’s tag system, where each tag becomes its own generated route.
What to remember
[slug].astrois a dynamic route; the bracket name must match the key inparams.getStaticPathsreturns the full list of pages to prerender — one object per URL.paramsbuilds the URL;propscarries data into the page viaAstro.props.satisfies GetStaticPathstype-checks the return shape without losing type detail.- Because everything is generated at build time, the result is plain static HTML — fast and easy to host.