Project: Personal Blog
A personal blog is the canonical Astro project: it leans on content collections for type-safe Markdown, ships zero JavaScript by default, and renders to static HTML that loads instantly. In this project you’ll build a complete blog with a typed post collection, tag pages, paginated listings, dark mode, and a syndication-ready RSS feed. Every piece uses real, modern Astro 5 APIs you can copy into a fresh project.
Project structure
Scaffold a fresh project, then add the integrations you’ll need. The @astrojs/rss package generates the feed, and @astrojs/sitemap helps search engines.
npm create astro@latest personal-blog
cd personal-blog
npm install @astrojs/rss @astrojs/sitemap
A blog organizes content under src/content/, pages under src/pages/, and a small amount of shared layout:
src/
content/
config.ts # collection schema
blog/
first-post.md
hello-astro.md
layouts/
BaseLayout.astro
pages/
index.astro
blog/[...page].astro # paginated list
posts/[slug].astro # single post
tags/[tag].astro # tag archive
rss.xml.js # feed endpoint
Defining the content collection
Collections give your Markdown frontmatter a Zod schema, so a typo in a date or a missing title fails the build instead of silently shipping. Define the schema in src/content/config.ts.
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updated: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
A post file then looks like this:
---
title: "Hello, Astro"
description: "Why I rebuilt my blog on Astro."
pubDate: 2026-06-01
tags: ["astro", "webdev"]
---
This is my first post written in **Markdown**.
Use
z.coerce.date()rather thanz.date()so plain2026-06-01strings in frontmatter parse into realDateobjects automatically.
Paginated post listing
Astro’s paginate() helper turns a sorted array of posts into numbered pages. The route file uses a rest parameter [...page] so /blog and /blog/2 both resolve.
---
import { getCollection } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
import type { GetStaticPaths } from "astro";
export const getStaticPaths = (async ({ paginate }) => {
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return paginate(posts, { pageSize: 5 });
}) satisfies GetStaticPaths;
const { page } = Astro.props;
---
<BaseLayout title="Blog">
<ul>
{page.data.map((post) => (
<li>
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</li>
))}
</ul>
<nav>
{page.url.prev && <a href={page.url.prev}>Newer</a>}
<span>Page {page.currentPage} of {page.lastPage}</span>
{page.url.next && <a href={page.url.next}>Older</a>}
</nav>
</BaseLayout>
The page object exposes everything you need for navigation:
| Property | Description |
|---|---|
page.data | 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 |
Rendering a single post
Each post gets a static route from its slug. Call post.render() to get the compiled <Content /> component plus a headings array for a table of contents.
---
import { getCollection } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BaseLayout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<p>{post.data.tags.map((t) => <a href={`/tags/${t}`}>#{t}</a>)}</p>
<Content />
</article>
</BaseLayout>
Tag archive pages
To build a page per tag, flatten every post’s tags into a unique set and generate one route for each. Filter posts that include that tag.
---
import { getCollection } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
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;
---
<BaseLayout title={`Tagged: ${tag}`}>
<h1>Posts tagged "{tag}"</h1>
<ul>
{posts.map((p) => <li><a href={`/posts/${p.slug}`}>{p.data.title}</a></li>)}
</ul>
</BaseLayout>
Dark mode with zero hydration
Theme toggling needs a sprinkle of client JavaScript, but Astro keeps it to a single inline island. Read the saved preference before paint to avoid a flash, then toggle a class on <html>.
<button id="theme-toggle" aria-label="Toggle theme">🌙</button>
<script is:inline>
const saved = localStorage.getItem("theme");
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", saved === "dark" || (!saved && prefersDark));
</script>
<script>
document.getElementById("theme-toggle")?.addEventListener("click", () => {
const isDark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
</script>
The
is:inlinescript runs synchronously in the<head>before content renders, which prevents the white-flash that plagues client-side theme switches.
Generating the RSS feed
A feed lets readers subscribe in any reader. The @astrojs/rss package builds valid XML from your collection. Create src/pages/rss.xml.js.
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
export async function GET(context) {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return rss({
title: "My Personal Blog",
description: "Notes on web development.",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/posts/${post.slug}/`,
})),
});
}
Set site in astro.config.mjs so context.site resolves to an absolute URL:
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://example.com",
integrations: [sitemap()],
});
Visit /rss.xml in dev to confirm it renders:
Output:
<rss version="2.0">
<channel>
<title>My Personal Blog</title>
<item>
<title>Hello, Astro</title>
<link>https://example.com/posts/hello-astro/</link>
<pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
Best practices
- Keep all post frontmatter under a strict Zod schema so a bad date or missing field breaks the build, not production.
- Filter
draftposts in every collection query (getStaticPaths, RSS, tags) to avoid leaking unfinished work. - Sort by
pubDate.valueOf()once in your listing route rather than relying on filesystem order. - Inline the theme-detection script with
is:inlinein the head to eliminate the flash of incorrect theme. - Always set
sitein the config so RSS links, the sitemap, and canonical URLs are absolute. - Prefer static output for blogs; reserve
output: "server"for genuinely dynamic routes to keep the zero-JS, instant-load advantage.