ehsan.blog
~/blog/how-to-build-dynamic-post-routes-with-getstaticpaths-in-astro — zsh
cat how-to-build-dynamic-post-routes-with-getstaticpaths-in-astro.md

How to build dynamic post routes with getStaticPaths in Astro

·5 min read

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:

astro
// 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

ts
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:

ts
// 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

ts
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:

  • params fills in the bracketed part of the filename. Because the file is [slug].astro, the key here must be slug. Setting slug: post.id tells Astro “build a page at the URL matching this id.” So a post whose id is hello-world becomes the page /hello-world.
  • props is arbitrary data passed into the page for that specific build. Here I pass the whole post object 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 --> F

Step 3: use the props to render

Below the second --- fence, the page reads its props and renders:

astro
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?

ts
export const getStaticPaths = (async () => {
  // ...
}) satisfies GetStaticPaths

GetStaticPaths 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 propswithout 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:

  • params answers “what URL is this?” — it is part of the route itself and shows up in Astro.params.
  • props answers “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:

  1. Create src/pages/[slug].astro.
  2. Export a getStaticPaths that returns an array of { params: { slug }, props }.
  3. Read Astro.props below the frontmatter fence and render.
  4. Run astro build and 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].astro is a dynamic route; the bracket name must match the key in params.
  • getStaticPaths returns the full list of pages to prerender — one object per URL.
  • params builds the URL; props carries data into the page via Astro.props.
  • satisfies GetStaticPaths type-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.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.