← Back to DevBytes

How to Create a Blog with Astro and Markdown

What Is Astro and Why Use Markdown for Blogging

Astro is a modern static site builder that ships zero JavaScript by default. Unlike heavy single-page application frameworks, Astro renders everything at build time into pure HTML and CSS, with the option to hydrate interactive components only when needed. When paired with Markdown, it becomes an exceptionally fast and developer-friendly platform for creating content-driven websites like blogs, documentation, and portfolios.

Markdown itself is a lightweight markup language designed for readability and ease of writing. You compose posts in plain text with simple syntax for headings, links, images, and code blocks, then Astro transforms them into fully formed HTML pages. This combination means you can write content in a distraction-free format while still delivering a polished, high-performance website to your readers.

Why This Combination Matters

Project Setup and Initial Structure

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

First, create a new Astro project using the official scaffolding tool. Open your terminal and run the following command, selecting the "Empty" template when prompted to keep things minimal:

npm create astro@latest my-astro-blog
cd my-astro-blog
npm install

After the installation completes, your project structure will look similar to this:

my-astro-blog/
├── astro.config.mjs
├── package.json
├── public/
│   └── favicon.svg
├── src/
│   ├── components/
│   ├── layouts/
│   ├── pages/
│   │   └── index.astro
│   └── styles/
└── tsconfig.json (if using TypeScript)

The src/pages directory is where file-based routing happens. Any .astro, .md, or .mdx file placed here automatically becomes a page. For a proper blog, however, we want organized content and reusable layouts, so let's expand this structure.

Setting Up the Content Directory

Astro's content collections feature requires a specific directory structure. Create the following folder and file at the root of your project:

src/content/
├── blog/
│   ├── first-post.md
│   ├── second-post.md
│   └── third-post.md
└── config.ts

The config.ts file defines the schema for your blog posts. This is where you'll validate frontmatter fields like title, date, tags, and description. Here's a complete configuration:

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

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishDate: z.date(),
    updatedDate: z.date().optional(),
    tags: z.array(z.string()).optional(),
    draft: z.boolean().default(false),
    featuredImage: z.string().optional(),
  }),
});

export const collections = {
  blog: blogCollection,
};

This schema gives you type safety throughout your templates. If you misspell a frontmatter field or use the wrong type, Astro will warn you during development and fail the build, preventing broken pages from going live.

Writing Your First Blog Post in Markdown

A typical Markdown post lives at src/content/blog/first-post.md. The top of the file contains frontmatter enclosed between triple dashes, and the body follows as standard Markdown:

---
title: "Getting Started with Astro Blogging"
description: "A step-by-step guide to building your first blog with Astro and Markdown"
publishDate: 2025-01-15
tags: ["astro", "markdown", "tutorial"]
draft: false
---

Welcome to your new Astro blog! In this post, we'll explore the fundamentals.

## Why Static Sites Are Making a Comeback

Static sites offer unparalleled performance. Without a database or server-side rendering
on each request, your pages load **instantly**.

### Key Benefits

1. Security — no dynamic code execution
2. Speed — pre-built HTML files
3. Simplicity — just files on a server

Here's a quick code snippet to demonstrate Astro's component syntax:

astro
---
const greeting = "Hello, world!";
---
<h1>{greeting}</h1>
That's it for now. Happy blogging!

Notice how the frontmatter fields match exactly what we defined in the schema. The publishDate uses a date format that Zod automatically parses. Tags are optional but useful for filtering and categorization later.

Creating a Blog Post Layout

Layouts in Astro are reusable page wrappers. Create a layout specifically for blog posts that handles the HTML document structure, metadata, and any shared styling:

// src/layouts/BlogPostLayout.astro
---
import { Image } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';

interface Props {
  post: CollectionEntry<'blog'>;
}

const { post } = Astro.props;
const { data, body } = post;
const formattedDate = new Date(data.publishDate).toLocaleDateString('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
});
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content={data.description} />
    <title>{data.title} — My Astro Blog</title>
    <link rel="stylesheet" href="/styles/global.css" />
  </head>
  <body>
    <header>
      <a href="/">← Back to Home</a>
    </header>

    <article>
      <h1>{data.title}</h1>
      <time datetime={data.publishDate.toISOString()}>{formattedDate}</time>

      {data.tags && data.tags.length > 0 && (
        <ul class="tags">
          {data.tags.map(tag => <li><span class="tag">#{tag}</span></li>)}
        </ul>
      )}

      <div class="post-content">
        <Content />
      </div>
    </article>

    <footer>
      <p>© {new Date().getFullYear()} My Astro Blog. All rights reserved.</p>
    </footer>
  </body>
</html>

The <Content /> component is a special Astro built-in that renders the Markdown body directly into the slot. It's automatically available when you work with content collections and receives the rendered HTML from your Markdown file. The layout receives a post prop containing both the frontmatter data and the rendered body.

Generating Individual Blog Post Pages

To create a dedicated page for each blog post, use Astro's dynamic routing with the getStaticPaths function. Create a file named src/pages/blog/[...slug].astro:

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

export async function getStaticPaths() {
  const blogPosts = await getCollection('blog', ({ data }) => {
    return import.meta.env.PROD ? !data.draft : true;
  });

  return blogPosts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
---

<BlogPostLayout post={post} />

This file does several important things:

The rest-spread parameter [...slug] in the filename tells Astro to match any number of path segments, so you could organize posts in subdirectories like blog/2025/first-post and it would still work correctly.

Building the Blog Index Page

Now create a listing page that displays all published posts. This goes in src/pages/index.astro (or src/pages/blog/index.astro if you prefer a dedicated blog section):

// src/pages/index.astro
---
import { getCollection } from 'astro:content';

const blogPosts = await getCollection('blog', ({ data }) => {
  return import.meta.env.PROD ? !data.draft : true;
});

// Sort by publish date, newest first
const sortedPosts = blogPosts.sort((a, b) => 
  b.data.publishDate.getTime() - a.data.publishDate.getTime()
);
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Astro Blog — Home</title>
    <link rel="stylesheet" href="/styles/global.css" />
  </head>
  <body>
    <header>
      <h1>My Astro Blog</h1>
      <p>Thoughts on web development, performance, and static sites.</p>
    </header>

    <main>
      <h2>All Posts</h2>

      {sortedPosts.length === 0 && <p>No posts published yet. Check back soon!</p>}

      <ul class="post-list">
        {sortedPosts.map(post => (
          <li>
            <a href={`/blog/${post.slug}`}>
              <article>
                <h3>{post.data.title}</h3>
                <time datetime={post.data.publishDate.toISOString()}>
                  {new Date(post.data.publishDate).toLocaleDateString('en-US', {
                    year: 'numeric',
                    month: 'long',
                    day: 'numeric',
                  })}
                </time>
                <p>{post.data.description}</p>

                {post.data.tags && post.data.tags.length > 0 && (
                  <ul class="tags">
                    {post.data.tags.map(tag => <li class="tag">#{tag}</li>)}
                  </ul>
                )}
              </article>
            </a>
          </li>
        ))}
      </ul>
    </main>

    <footer>
      <p>© {new Date().getFullYear()} My Astro Blog. All rights reserved.</p>
    </footer>
  </body>
</html>

This index page fetches the same collection, sorts posts by publication date descending, and renders a linked list with titles, dates, descriptions, and tags. Each link points to the dynamic route we set up earlier.

Adding RSS Feed Support

An RSS feed lets readers subscribe to your blog. Astro doesn't include RSS generation out of the box, but it's straightforward to build. Create a file at src/pages/rss.xml.js (note the .js or .ts extension — this is an endpoint, not an Astro component):

// src/pages/rss.xml.js
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';

export async function GET(context) {
  const blogPosts = await getCollection('blog', ({ data }) => {
    return !data.draft;
  });

  const sortedPosts = blogPosts.sort((a, b) =>
    b.data.publishDate.getTime() - a.data.publishDate.getTime()
  );

  return rss({
    title: 'My Astro Blog',
    description: 'Thoughts on web development, performance, and static sites.',
    site: context.site,
    items: sortedPosts.map(post => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.publishDate,
      link: `/blog/${post.slug}`,
      customData: post.data.tags?.map(tag => `<category>${tag}</category>`).join('') || '',
    })),
    stylesheet: '/rss-styles.xsl',
  });
}

First install the RSS helper package:

npm install @astrojs/rss

Then make sure your astro.config.mjs includes the site property, which RSS generation needs for absolute URLs:

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  site: 'https://my-astro-blog.example.com',
});

Tag-Based Filtering Pages

To let readers browse posts by tag, create another dynamic route at src/pages/tags/[tag].astro:

// src/pages/tags/[tag].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const blogPosts = await getCollection('blog', ({ data }) => {
    return import.meta.env.PROD ? !data.draft : true;
  });

  // Collect unique tags
  const tagSet = new Set();
  blogPosts.forEach(post => {
    post.data.tags?.forEach(tag => tagSet.add(tag));
  });

  return Array.from(tagSet).map(tag => ({
    params: { tag },
    props: {
      posts: blogPosts.filter(post =>
        post.data.tags?.includes(tag)
      ).sort((a, b) =>
        b.data.publishDate.getTime() - a.data.publishDate.getTime()
      ),
      tag,
    },
  }));
}

const { posts, tag } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Posts tagged #{tag} — My Astro Blog</title>
    <link rel="stylesheet" href="/styles/global.css" />
  </head>
  <body>
    <header>
      <a href="/">← Back to Home</a>
      <h1>Posts tagged #{tag}</h1>
      <p>{posts.length} post{posts.length !== 1 ? 's' : ''} found</p>
    </header>

    <main>
      <ul class="post-list">
        {posts.map(post => (
          <li>
            <a href={`/blog/${post.slug}`}>
              <strong>{post.data.title}</strong>
              <span> — {new Date(post.data.publishDate).toLocaleDateString('en-US', {
                year: 'numeric',
                month: 'short',
                day: 'numeric',
              })}</span>
            </a>
          </li>
        ))}
      </ul>
    </main>
  </body>
</html>

This page generates a static URL for every unique tag across all blog posts. At build time, Astro runs getStaticPaths, collects all distinct tags, and produces one HTML file per tag containing only the relevant posts. This is excellent for SEO because each tag gets its own dedicated, indexable page.

Styling with Scoped CSS

Astro supports component-scoped styles using <style> tags directly in .astro files. The styles are automatically scoped to that component, preventing leakage across pages. Here's an example of styling the blog index:

// Add this inside src/pages/index.astro, after the HTML template
<style is:global>
  /* Global styles reset — only use :global sparingly */
  *,
  *::before,
  *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }

  body {
    font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    line-height: 1.6;
    color: #333;
    max-width: 720px;
    margin: 0 auto;
    padding: 2rem 1rem;
  }
</style>

<style>
  /* These styles are scoped to this component only */
  .post-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
  }

  .post-list li a {
    text-decoration: none;
    color: inherit;
    display: block;
    padding: 1rem;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
    transition: box-shadow 0.2s ease;
  }

  .post-list li a:hover,
  .post-list li a:focus-visible {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }

  .post-list h3 {
    margin-bottom: 0.25rem;
    color: #1a1a1a;
  }

  time {
    font-size: 0.9rem;
    color: #666;
    display: block;
    margin-bottom: 0.5rem;
  }

  .tags {
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-top: 0.75rem;
  }

  .tag {
    background: #f0f0f0;
    padding: 0.2rem 0.6rem;
    border-radius: 4px;
    font-size: 0.8rem;
    color: #555;
  }
</style>

The is:global attribute on a style tag removes scoping, which is useful for base resets. Regular scoped styles keep your component styles cleanly separated. For shared styles across multiple components, consider creating a src/styles/global.css file and linking it in your layouts.

Deploying Your Astro Blog

Astro sites are static by nature, so deployment is straightforward across many platforms. First, build the production-ready site:

npm run build

This generates a dist directory containing fully static HTML, CSS, and JavaScript files. You can deploy this folder to any static hosting service. Here are common deployment options:

Best Practices for Astro + Markdown Blogs

1. Keep Frontmatter Consistent

Define a strict Zod schema in your content config and stick to it across all Markdown files. This prevents subtle bugs where one post uses date and another uses publishDate. Run astro check (if using TypeScript) or pay attention to console warnings during development to catch schema violations early.

2. Use Draft Mode Liberally

The draft boolean in frontmatter is a powerful workflow tool. Write posts incrementally, keep them as drafts, and preview them locally. They'll only appear in production when you explicitly set draft: false. This lets you maintain a content pipeline without publishing incomplete work.

3. Organize Content with Subdirectories

As your blog grows, organize Markdown files into subdirectories by year, category, or series:

src/content/blog/
├── 2024/
│   ├── launch-announcement.md
│   └── q4-retrospective.md
├── 2025/
│   ├── new-features.md
│   └── migration-guide.md
└── series/
    └── astro-deep-dive/
        ├── part-1.md
        ├── part-2.md
        └── part-3.md

The [...slug] route handles nested paths automatically, so series/astro-deep-dive/part-1.md becomes accessible at /blog/series/astro-deep-dive/part-1.

4. Optimize Images

Use Astro's built-in image optimization with the Image component from astro:assets. Store images in src/assets and import them in your layouts or directly in Markdown frontmatter. Astro will automatically resize, convert formats, and generate responsive srcset attributes at build time.

5. Leverage remark/rehype Plugins

Enhance your Markdown processing with plugins. Install them and configure in astro.config.mjs:

// astro.config.mjs with Markdown plugins
import { defineConfig } from 'astro/config';
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';

export default defineConfig({
  site: 'https://my-astro-blog.example.com',
  markdown: {
    remarkPlugins: [remarkToc],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
    ],
  },
});

This automatically adds IDs to headings, generates a table of contents, and wraps headings in anchor links — all without modifying your Markdown source files.

6. Implement Pagination

When you have dozens or hundreds of posts, paginate your index page using Astro's built-in pagination API. Modify your getStaticPaths in the index page to slice the sorted posts array into chunks and generate multiple pages like /page/1, /page/2, etc.

// Example pagination in src/pages/index.astro
export async function getStaticPaths({ paginate }) {
  const blogPosts = await getCollection('blog', ({ data }) => {
    return import.meta.env.PROD ? !data.draft : true;
  });

  const sortedPosts = blogPosts.sort((a, b) =>
    b.data.publishDate.getTime() - a.data.publishDate.getTime()
  );

  return paginate(sortedPosts, { pageSize: 10 });
}

const { page } = Astro.props;
// page.data contains the posts for this page
// page.url.prev / page.url.next give pagination links

7. Add a Sitemap

Generate an XML sitemap for search engines. Install @astrojs/sitemap and add it to your Astro config as an integration:

npm install @astrojs/sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://my-astro-blog.example.com',
  integrations: [sitemap()],
});

This automatically generates a /sitemap-index.xml and individual sitemaps for all static pages, including your dynamically generated blog posts and tag pages.

8. Use Descriptive Slugs

Name your Markdown files with descriptive, URL-friendly slugs rather than numbers or vague titles. A file named building-rest-api-with-astro.md produces the URL /blog/building-rest-api-with-astro, which is far better for SEO and readability than /blog/post-7.

Conclusion

Building a blog with Astro and Markdown gives you a remarkably fast, maintainable, and scalable publishing platform. You write content in clean, version-controlled Markdown files, define type-safe schemas for your frontmatter, and let Astro generate optimized static HTML at build time. The result is a website that loads instantly, ranks well in search engines, and costs almost nothing to host.

The workflow we covered — from project initialization and content collections to dynamic routing, tag filtering, RSS feeds, and deployment — provides a complete foundation for any blog, whether it has five posts or five hundred. By following the best practices around schema consistency, draft management, plugin usage, and pagination, you'll have a blog that grows gracefully with your content while remaining a joy to develop and maintain.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles