Building a Blog
A blog is the canonical Astro project: mostly static content, authored in Markdown, rendered to fast HTML with zero JavaScript by default. This recipe wires together the pieces that turn a folder of .md files into a real blog — a type-safe content collection, dynamic routes for each post, tag archives, paginated index pages, and a standards-compliant RSS feed. Everything here uses modern Astro 5 APIs and produces a fully static site you can deploy anywhere.
Defining the posts collection
Content collections give your Markdown frontmatter a Zod schema, so a typo in a date or a missing title fails the build instead of shipping broken pages. Define a blog collection in src/content.config.ts using the glob loader, which reads files from disk and exposes them through the content layer.
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
A post is then just a Markdown file under src/content/blog/:
---
title: "Hello Astro"
description: "My first post on the new site."
pubDate: 2026-06-14
tags: ["astro", "intro"]
---
Welcome to the blog. **Astro** ships this as pure HTML.
z.coerce.date()parses the YAML date string (or a quoted value) into a realDate. Withoutcoerce, an unquoted date can be mis-typed by the YAML parser — always let the schema do the conversion.
Listing posts and a reusable query
You will need the same “published posts, newest first” query in several places, so factor it into a small helper. getCollection accepts a filter callback that runs at build time.
import { getCollection } from "astro:content";
export async function getPublishedPosts() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
}
Dynamic routes for each post
Each post needs its own URL. Create src/pages/blog/[...slug].astro and use getStaticPaths to generate one page per entry. The entry’s render() method returns a <Content /> component that renders the Markdown body.
---
import { render } from "astro:content";
import { getPublishedPosts } from "../../lib/posts";
import Layout from "../../layouts/Layout.astro";
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<Layout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
<Content />
</article>
</Layout>
Because the route is prerendered, the resulting HTML contains no client JavaScript — the <Content /> island only adds JS if a post embeds an interactive component with a client:* directive.
Tag pages
Tag archives follow the same pattern: collect every unique tag, then emit a page per tag whose props carry the matching posts. Create src/pages/tags/[tag].astro.
---
import { getPublishedPosts } from "../../lib/posts";
import Layout from "../../layouts/Layout.astro";
export async function getStaticPaths() {
const posts = await getPublishedPosts();
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: { posts: posts.filter((p) => p.data.tags.includes(tag)) },
}));
}
const { tag } = Astro.params;
const { posts } = Astro.props;
---
<Layout title={`Posts tagged ${tag}`}>
<h1>#{tag}</h1>
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.id}`}>{post.data.title}</a></li>
))}
</ul>
</Layout>
Paginating the index
For a long-running blog, paginate the listing so the homepage stays small. The paginate() helper passed into getStaticPaths slices your posts and generates /blog/1, /blog/2, and so on. Name the file src/pages/blog/[...page].astro.
---
import type { GetStaticPaths, Page } from "astro";
import { getPublishedPosts } from "../../lib/posts";
import Layout from "../../layouts/Layout.astro";
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getPublishedPosts();
return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;
const { page } = Astro.props as { page: Page<Awaited<ReturnType<typeof getPublishedPosts>>[number]> };
---
<Layout title={`Blog — page ${page.currentPage}`}>
<ul>
{page.data.map((post) => (
<li><a href={`/blog/${post.id}`}>{post.data.title}</a></li>
))}
</ul>
<nav>
{page.url.prev && <a href={page.url.prev}>Previous</a>}
{page.url.next && <a href={page.url.next}>Next</a>}
</nav>
</Layout>
The page prop exposes everything you need for navigation:
| Property | Description |
|---|---|
page.data | Array of items for the current page |
page.currentPage | 1-based page number |
page.lastPage | Total number of pages |
page.url.prev | URL of the previous page, or undefined |
page.url.next | URL of the next page, or undefined |
An RSS feed
Readers and aggregators expect RSS. Install the official integration and expose a feed endpoint.
npx astro add rss
Create src/pages/rss.xml.ts as an endpoint that returns the feed XML:
import rss from "@astrojs/rss";
import type { APIContext } from "astro";
import { getPublishedPosts } from "../lib/posts";
export async function GET(context: APIContext) {
const posts = await getPublishedPosts();
return rss({
title: "DevCraftly Blog",
description: "Posts about building with Astro.",
site: context.site!,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/blog/${post.id}/`,
})),
});
}
Set site in astro.config.mjs so context.site resolves to an absolute URL, then visit the feed.
Output:
$ curl http://localhost:4321/rss.xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>DevCraftly Blog</title>
<item><title>Hello Astro</title>...</item>
</channel></rss>
Best practices
- Filter out
draft: trueposts in a single shared query so unpublished content never leaks into pages, tags, or the RSS feed. - Use
z.coerce.date()for dates and quote ambiguous frontmatter values to keep the YAML parser from mis-typing them. - Trailing slashes matter for RSS — match the
linkin feed items to your site’strailingSlashsetting to avoid redirects in readers. - Keep blog pages static (prerendered); only reach for
client:*islands on the rare interactive widget embedded in a post. - Generate tag and pagination URLs from
post.idand thepagehelper rather than hardcoding paths, so renames stay consistent. - Set
siteinastro.config.mjsearly — canonical URLs, sitemaps, and RSS all depend on it.