Skip to content
Astro as patterns 5 min read

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 real Date. Without coerce, 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:

PropertyDescription
page.dataArray of items for the current page
page.currentPage1-based page number
page.lastPageTotal number of pages
page.url.prevURL of the previous page, or undefined
page.url.nextURL 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: true posts 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 link in feed items to your site’s trailingSlash setting 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.id and the page helper rather than hardcoding paths, so renames stay consistent.
  • Set site in astro.config.mjs early — canonical URLs, sitemaps, and RSS all depend on it.
Last updated June 14, 2026
Was this helpful?