← Back to blog

Building a Blog with Astro

Astro makes it surprisingly easy to build fast, content-driven sites — and that includes blogs. In this article, I’ll walk you through how I set up a simple, flexible blog using Astro’s Content Collections, Markdown, and a custom layout.

By the end, you’ll have: • A /blog page that lists posts with title, date, and description • A dynamic [slug].astro page that renders each post • A reusable BlogPost.astro layout • A nice developer experience with type safety from astro:content


Step 1: Define the Blog Collection

Astro’s content/config.ts lets you define structured content collections. This is where we tell Astro what fields each post should have and what shape of data we expect.

// src/content/config.ts
import { z } from 'zod';
import { defineCollection } from 'astro:content';

const blogSchema = z.object({
  title: z.string(),
  date: z.string(), // ISO date string
  tags: z.array(z.string()).optional(),
  description: z.string().optional(),
  slug: z.string().optional(),
});

export const collections = {
  blog: defineCollection({ schema: blogSchema }),
};

This schema gives us type-safe access to each post’s frontmatter and guarantees we won’t accidentally forget a title or date.


Step 2: Create Your First Post

Astro supports Markdown and MDX. Here’s a sample hello-world.md:

---
title: 'Hello, world'
date: '2025-09-17'
tags: ['intro', 'astro']
description: 'An introduction to the blog and what to expect'
slug: 'hello-world'
---

Welcome to my new blog! I'll be sharing notes about front-end development...

Each post lives under src/content/blog/. The slug is optional, but it gives you control over the final URL.

Step 3: Build the Blog Index

The main /blog page fetches all posts from the collection, sorts them by date, and lists them:

---
// src/pages/blog.astro
import { getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import Layout from '@layouts/Layout.astro';

const posts = (await getCollection(
  'blog'
)) as CollectionEntry<'blog'>[];
posts.sort(
  (a, b) =>
    new Date(a.data.date).getTime() -
    new Date(b.data.date).getTime()
);
---

<Layout>
  <section class="mx-auto max-w-4xl px-4 py-16">
    <h1 class="text-5xl font-extrabold">Blog</h1>

    <div class="space-y-6">
      {
        posts.map((post) => (
          <article class="group">
            <a href={`/blog/${post.slug || post.id}`}>
              <h2 class="text-2xl font-semibold">
                {post.data.title}
              </h2>
              <p class="text-sm text-gray-500">
                {new Date(post.data.date).toLocaleDateString()}
              </p>
              <p>{post.data.description}</p>
            </a>
          </article>
        ))
      }
    </div>
  </section>
</Layout>

This gives you a clean, simple index page.


Step 4: Create the Dynamic Post Page

Astro’s file-based routing makes this straightforward:

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import BlogPost from '@layouts/BlogPost.astro';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => {
    const raw = post.data.slug ?? post.slug ?? post.id;
    const s = String(raw)
      .replace(/\.(md|mdx)$/, '')
      .split('/')
      .pop();
    return { params: { slug: s } };
  });
}

const { slug } = Astro.params;
const posts = await getCollection('blog');
const post = posts.find((p) => {
  const raw = p.data.slug ?? p.slug ?? p.id;
  const entrySlug = String(raw)
    .replace(/\.(md|mdx)$/, '')
    .split('/')
    .pop();
  return entrySlug === slug;
});

if (!post) throw new Error(`Post not found: ${slug}`);

const rendered = await (post as any).render();
const Content = rendered.Content;
---

<BlogPost frontmatter={post.data} Content={Content} />

This handles URL generation and fetches the right post data.


Step 5: Create a Reusable Layout

Finally, a simple layout to render post content:

---
// src/layouts/BlogPost.astro
import Layout from './Layout.astro';
const { frontmatter, Content } = Astro.props;
---

<Layout>
  <article class="mx-auto max-w-3xl px-4 py-16">
    <header class="mb-6">
      <a href="/blog" class="text-sm hover:underline"
        >← Back to blog</a
      >
      <h1 class="text-4xl font-extrabold">
        {frontmatter.title}
      </h1>
      <time class="text-sm text-gray-500">
        {new Date(frontmatter.date).toLocaleDateString()}
      </time>
    </header>

    <div class="prose max-w-none dark:prose-invert">
      <Content />
    </div>
  </article>
</Layout>

This keeps your post pages consistent and easy to style.


Why This Setup Works


Next Steps

Astro’s content collections make this kind of workflow not just possible, but delightful. If you’re looking to add a blog to an Astro site, this approach is a great foundation.