Astro is purpose-built for content-heavy websites like blogs, documentation sites, and portfolios. It ships zero JavaScript to the browser by default, which makes your blog one of the fastest on the web. With content collections, you get type-safe Markdown/MDX authoring, automatic validation, and built-in image optimization. This guide builds a complete blog with content collections, RSS, and a sitemap.
Prerequisites
- →Node.js 20+
Astro requires Node.js 20 or later.
- →Basic HTML and CSS
Astro components use a superset of HTML, so HTML fundamentals are essential.
- →Familiarity with Markdown
Blog posts are written in Markdown with frontmatter metadata.
Create the Astro Project
Scaffold a new Astro project using the CLI wizard. Choose the blog template as a starting point, then add Tailwind CSS and sitemap integrations. Astro's CLI is interactive and sets up everything including TypeScript configuration.
npm create astro@latest my-blog -- --template blog
cd my-blog
npx astro add tailwind
npx astro add sitemap
npm installTip: The 'astro add' command automatically configures integrations in your astro.config.mjs.
Tip: Choose 'strict' TypeScript mode for the best developer experience with content collections.
Define Content Collections with a Schema
Content collections let you define a typed schema for your blog posts using Zod. Astro validates every Markdown file against this schema at build time, catching errors like missing titles or invalid dates before they reach production. Define your collection in the src/content directory.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };Tip: The schema acts as documentation for your content format — new team members instantly know what fields are available.
Tip: Use z.coerce.date() instead of z.date() so string dates in frontmatter are automatically parsed.
Write Your First Blog Post
Create a Markdown file in the content/blog directory with frontmatter that matches your schema. Astro will validate the frontmatter at build time and give you clear error messages if anything is wrong. You can use standard Markdown syntax plus any remark/rehype plugins you add.
---
title: "Why I Switched to Astro"
description: "How Astro's zero-JS approach cut my blog's load time by 80%."
pubDate: 2026-03-10
tags: ["astro", "performance", "web"]
heroImage: "/images/astro-hero.jpg"
---
I rebuilt my blog with Astro and the results were dramatic.
My Lighthouse performance score went from 72 to 100.
## The Problem with JavaScript-Heavy Blogs
Most blog frameworks ship a full React runtime to the browser,
even though blog posts are entirely static content.
## How Astro Solves This
Astro renders everything to HTML at build time and ships
**zero JavaScript** by default. If you need interactivity,
you opt in with [client directives](https://docs.astro.build/en/reference/directives-reference/).
```astro
<!-- Only hydrate when visible -->
<Counter client:visible />
```Build the Blog Listing Page
Create the blog index page that queries your content collection and displays all published posts sorted by date. Astro's getCollection function returns typed post data that matches your schema. The page is rendered to static HTML at build time with zero JavaScript.
---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
import FormattedDate from "../../components/FormattedDate.astro";
const posts = (await getCollection("blog"))
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<BaseLayout title="Blog" description="All blog posts">
<main class="mx-auto max-w-2xl px-4 py-16">
<h1 class="mb-12 text-4xl font-bold">Blog</h1>
<ul class="space-y-8">
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`} class="group block">
<FormattedDate date={post.data.pubDate} />
<h2 class="mt-1 text-xl font-semibold group-hover:text-blue-600">
{post.data.title}
</h2>
<p class="mt-2 text-gray-600">{post.data.description}</p>
</a>
</li>
))}
</ul>
</main>
</BaseLayout>Create Dynamic Post Pages
Build individual post pages using Astro's dynamic routing with getStaticPaths. Each post is rendered at build time to its own HTML page. The render() function converts Markdown to HTML, and you get access to all frontmatter data through the entry.data object.
---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
import FormattedDate from "../../components/FormattedDate.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article class="mx-auto max-w-2xl px-4 py-16">
<header class="mb-8">
<FormattedDate date={post.data.pubDate} />
<h1 class="mt-2 text-4xl font-bold">{post.data.title}</h1>
<p class="mt-4 text-lg text-gray-600">{post.data.description}</p>
{post.data.heroImage && (
<img
src={post.data.heroImage}
alt={post.data.title}
class="mt-6 rounded-lg"
/>
)}
</header>
<div class="prose prose-lg max-w-none">
<Content />
</div>
</article>
</BaseLayout>Tip: Add a table of contents by using the remarkToc plugin or Astro's built-in heading extraction.
Tip: Use Astro's Image component for automatic image optimization in hero images.
Add an RSS Feed
Install the official Astro RSS package and create a feed endpoint. The RSS feed is generated at build time from your content collection, so subscribers get updates whenever you publish a new post and deploy. Search engines also use RSS feeds for content discovery.
// src/pages/rss.xml.ts
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const posts = (await getCollection("blog")).filter(
(post) => !post.data.draft
);
return rss({
title: "My Blog",
description: "Articles about web development",
site: context.site!,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.id}/`,
})),
});
}Tip: Install the package with 'npx astro add rss' and set the 'site' property in astro.config.mjs.
Tip: Add a link to your RSS feed in your layout's <head> for feed reader auto-discovery.
Deploy to Vercel or Netlify
Astro supports multiple deployment targets. For static hosting, build the site and deploy the dist folder. Both Vercel and Netlify have official Astro adapters that handle deployment configuration automatically. Your blog will be served from a global CDN with near-zero latency.
# Option 1: Deploy to Vercel
npx astro add vercel
npm run build
npx vercel --prod
# Option 2: Deploy to Netlify
npx astro add netlify
npm run build
npx netlify deploy --prod --dir=distTip: Astro's static output mode produces a dist folder that works on any static host, including GitHub Pages.
Tip: Enable Vercel's Edge Network or Netlify's CDN for sub-50ms global response times.
Next Steps
- →Add a search feature using Pagefind for zero-dependency client-side search.
- →Implement view transitions with Astro's built-in ViewTransitions component for smooth page navigation.
- →Add a CMS like Tina or Decap CMS for non-technical content editors.
- →Create tag archive pages by grouping posts from your content collection.