At this point in the series, you have Content Collections set up with a posts collection and a few Markdown files. But there’s still a gap: the content exists, but there are no URLs for it.
Content files in src/content/ don’t create pages automatically. That separation is intentional: the collection stores the data, and the route decides which URLs exist.
This article closes that gap.
Two pages to build
For a blog, you need two types of pages:
Listing page: shows all posts. URL: /blog.
Detail page: shows one post. URL: /blog/hello-world, /blog/astro-guide, etc.
The listing page is a normal static page. The detail page is a dynamic route, one file that generates many pages.
Part 1: The listing page
Create or update src/pages/blog.astro:
---
import { getCollection } from 'astro:content'
import BaseLayout from '../layouts/BaseLayout.astro'
const posts = await getCollection('posts')
---
<BaseLayout title="Blog - Astro Content Lab">
<div class="page-header">
<h1>Blog</h1>
<p>Thoughts, tutorials and lessons learned building with Astro.</p>
</div>
<div class="post-list">
{posts.map((post) => (
<a href={`/blog/${post.id}`} class="post-item">
<div class="post-item-content">
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
</div>
<span class="post-item-link">Read more -></span>
</a>
))}
</div>
</BaseLayout>
<style>
.page-header {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.page-header h1 { margin-bottom: 0.5rem; }
.page-header p {
color: var(--color-text-muted);
margin: 0;
}
.post-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.post-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
text-decoration: none;
color: var(--color-text);
background: var(--color-bg);
transition: all 0.2s;
gap: 1rem;
}
.post-item:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
text-decoration: none;
color: var(--color-text);
}
.post-item-content h2 {
font-size: 1.0625rem;
margin: 0 0 0.35rem;
}
.post-item-content p {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.post-item-link {
font-size: 0.875rem;
color: var(--color-primary);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 640px) {
.post-item {
flex-direction: column;
align-items: flex-start;
}
}
</style>
The key part: getCollection('posts') returns all entries from the posts collection. Each entry has post.id (the filename without extension) and post.data (the validated frontmatter).
The URL for each post: /blog/${post.id}. If the file is hello-world.md, the URL is /blog/hello-world.
Part 2: The dynamic route
You have potentially dozens of blog posts. You don’t want to create a separate .astro file for each one. You want one template that works for all of them.
That’s a dynamic route.
Create src/pages/blog/[slug].astro:
---
import { getCollection, render } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
export async function getStaticPaths() {
const posts = await getCollection('posts')
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}))
}
const { post } = Astro.props
const { Content } = await render(post)
const publishedDate = new Date(post.data.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
---
<BaseLayout title={`${post.data.title} - Astro Content Lab`}>
<article class="post">
<div class="post-header">
<a href="/blog" class="back-link">← Back to Blog</a>
<h1>{post.data.title}</h1>
<p class="post-desc">{post.data.description}</p>
<p class="post-meta">Published {publishedDate}</p>
</div>
<div class="post-body">
<Content />
</div>
</article>
</BaseLayout>
<style>
.post {
max-width: 680px;
margin: 0 auto;
}
.back-link {
display: inline-block;
font-size: 0.875rem;
color: var(--color-text-muted);
text-decoration: none;
margin-bottom: 1.5rem;
}
.back-link:hover {
color: var(--color-primary);
}
.post-header {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.post-header h1 { margin-bottom: 0.75rem; }
.post-desc {
font-size: 1.125rem;
color: var(--color-text-muted);
margin-bottom: 0.75rem;
}
.post-meta {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.post-body {
line-height: 1.8;
}
</style>
Understanding getStaticPaths
getStaticPaths is the function that makes dynamic routes work. It’s required for any file with [brackets] in the name.
Here’s what it does:
export async function getStaticPaths() {
const posts = await getCollection('posts')
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}))
}
The question it answers: “Astro, here are all the URLs you need to generate.”
params: { slug: post.id } defines the URL. slug matches the [slug] in the filename. If post.id is hello-world, Astro generates /blog/hello-world.
props: { post } passes the post data to the page component. The page receives it via Astro.props.
The result: for 10 posts, getStaticPaths returns 10 objects. Astro generates 10 HTML files. One template, many outputs.
How params and props work together
This is the part that confused me longest:
return posts.map((post) => ({
params: { slug: post.id }, // defines the URL
props: { post }, // passes data to the page
}))
params controls the URL. { slug: 'hello-world' } creates /blog/hello-world.
props passes anything you want to the page component. You could pass only the id and fetch the post again in the component, but passing the full post object keeps this example easier to follow.
In the page component, you receive the post via Astro.props:
const { post } = Astro.props
And render the content with render(post):
const { Content } = await render(post)
Content is an Astro component that renders the Markdown body. Place <Content /> anywhere in the template and the full post body appears there.
The complete flow
When someone visits /blog/hello-world:
- Astro finds
src/pages/blog/[slug].astro - At build time,
getStaticPathsran and said “create/blog/hello-worldwith this post data” - The template rendered with that post data: title, description, date, and content
- The result is a static HTML file at
/blog/hello-world/index.html - Visitor requests the URL, and the server sends the pre-built HTML file
Nothing runs at request time. The page was built before anyone visited.
This is fundamentally different from WordPress, where every page request triggers PHP, queries the database, and assembles HTML in real time.
The listing page result
After adding the CSS and listing page, /blog should show one card per post. Each card links to a dynamic route like /blog/hello-world.
Clicking a card should load the detail page, where the same [slug].astro template renders the post title, date, description, and Markdown body.
The important part is not the exact styling. The important part is the relationship: add a new .md file to src/content/posts/, and the listing page plus detail route both pick it up on the next build.
Adding a new post
This is where the system pays off. To add a new blog post:
- Create a new
.mdfile insrc/content/posts/ - Add valid frontmatter (title, description, publishedAt)
- Write the content below the frontmatter dashes
That’s it. No code changes. The new post appears in the listing page, and its detail page is generated automatically on the next build.
touch src/content/posts/my-new-post.md
---
title: My New Post
description: A new post added without touching any code.
publishedAt: 2026-06-18T00:00:00Z
---
Content goes here.
Refresh /blog, and the new post appears. Visit /blog/my-new-post, and the page exists.
Troubleshooting
404 on detail pages after setup
Check that the params.slug in getStaticPaths matches the dynamic segment in the filename. If the file is [slug].astro, the params key must be slug.
post.data is undefined
The post didn’t come from Astro.props. Make sure getStaticPaths returns props: { post } and that you destructure const { post } = Astro.props at the top of the component.
Content not rendering
Make sure you called const { Content } = await render(post) and placed <Content /> in the template. The render() function must be imported from astro:content.
Posts not showing in listing
Restart the dev server. If still empty, verify the glob pattern in content.config.ts matches where your files are.