Skip to content
Astro projects 5 min read

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 than z.date() so plain 2026-06-01 strings in frontmatter parse into real Date objects 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:

PropertyDescription
page.dataItems 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

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:inline script 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 draft posts 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:inline in the head to eliminate the flash of incorrect theme.
  • Always set site in 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.
Last updated June 14, 2026
Was this helpful?