Astro v5 Content Collections で技術ブログを作る — r43lab.com を組んだ時の設計ノート

このサイト (r43lab.com) は Astro v5 + Content Layer API で組まれています。型安全 frontmatter、RSS 自動生成、カテゴリ/タグ別ページ、TOC 付きの個別記事レイアウト — これら全部を最小構成で組んだときの判断と落とし穴を、そのままコピペできるテンプレで残しておきます。

このサイト (r43lab.com) は Astro v5 + Content Layer API で組まれています。CMS ほど重くなく、Next.js MDX ほど自由すぎず、型安全な Markdown 駆動が欲しいときに素直にハマる選択肢が Content Collections です。本記事では、実際にこのサイトを組んだときに決めた設計と落とし穴を、コピペで動くテンプレとして残しておきます。

2026-04 追記: Astro v6 が安定リリース済み(2026-03)。本記事のコードは最初から Content Layer API(glob loader / render() / post.id)を前提にしているため、v6 でそのまま動きます。v6 で削除されたのは旧 src/content/<collection>/ ディレクトリ前提の legacy collections のみで、本記事は影響を受けません。

到達点と前提

  • 型安全な frontmatter(Zod 検証)
  • /blog/[slug] 個別記事ページ(TOC / パンくず / 前後記事 / 関連記事)
  • /blog/category/[cat]/blog/tag/[tag] を静的生成
  • RSS 自動生成 (/rss.xml)
  • @astrojs/sitemap で sitemap も自動(sitemap-index.xmlsitemap-0.xml の 2 段構成で出力)

検証バージョン (2026-04 時点): Astro v5.17(r43lab.com の現行)/ v6 互換 / @astrojs/rss / @astrojs/sitemap / TypeScript strict

1. Collection 定義 — src/content.config.ts

Astro v5 では Content Layer API が標準になり、loader を差し替えるだけでファイル以外のデータソースにも繋げる設計になりました。Markdown ブログなら glob ローダで十分です。

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

export const CATEGORIES = [
  "cloudflare",
  "claude-code",
  "astro",
  "typescript",
  "devops",
  "product",
] as const;

const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    heroImage: z.string().optional(),
    tags: z.array(z.string()).default([]),
    category: z.enum(CATEGORIES).default("product"),
    draft: z.boolean().default(false),
    author: z.string().default("r43"),
  }),
});

export const collections = { blog };

設計の判断ポイント:

  • categoryz.enum — 想定外のカテゴリを入れると build が止まるので、安全側に倒す
  • draft: z.boolean().default(false) — 草稿は getCollection("blog", ({ data }) => !data.draft) で一括除外
  • date: z.coerce.date() — 文字列も Date に自動変換
  • updatedDate は optional — 設定されていれば JSON-LD の dateModified として出す

2. 動的ルーティング — src/pages/blog/[slug].astro

---
import BlogPost from "../../layouts/BlogPost.astro";
import { getCollection, render } from "astro:content";

export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content, headings } = await render(post);
---

<BlogPost post={post} headings={headings}>
  <Content />
</BlogPost>

render(post) が返す headings は Markdown の h2/h3/… を抜き出した配列で、そのまま TOC を組むのに使えます。post.id は glob ローダがファイルパスを github-slugger で kebab-case にスラグ化した値で、URL スラグとしてそのまま使えます。Astro v5 以降では旧来の post.slug プロパティが廃止されているため、新たに slug フィールドを持たせる必要はありません

3. BlogPost レイアウト(必要要素の最短形)

src/layouts/BlogPost.astro では、ヘッダに記事メタを並べ、本文の左右どちらかに TOC を sticky 配置、末尾に prev/next と related を置きます。TOC のスクロール連動ハイライトは、Intersection Observer で 10 行ほどで書けます。

// Astro 本体の <script is:inline> の中身
const toc = document.querySelector('.post-toc');
if (toc) {
  const links = toc.querySelectorAll('a[href^="#"]');
  const map = new Map();
  links.forEach((a) => {
    const id = a.getAttribute('href').slice(1);
    const el = document.getElementById(id);
    if (el) map.set(el, a);
  });
  const obs = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      const a = map.get(entry.target);
      if (a && entry.isIntersecting) {
        links.forEach((l) => l.removeAttribute('data-active'));
        a.setAttribute('data-active', 'true');
      }
    });
  }, { rootMargin: '-20% 0px -70% 0px' });
  map.forEach((_, el) => obs.observe(el));
}

getStaticPaths で得たリスト全体をソートし、現在記事のインデックスから prev / next を取ると、前後記事リンクが静的に埋められます。category が同じ記事を 3 件抜けば関連記事も静的に出せます。

4. RSS / sitemap / カテゴリ・タグ

RSS は @astrojs/rss で書きます。

// 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))
    .sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
  return rss({
    title: "r43lab",
    description: "r43lab の技術ログ",
    site: context.site ?? "https://r43lab.com/",
    items: posts.map((p) => ({
      title: p.data.title,
      description: p.data.description,
      pubDate: p.data.date,
      link: `/blog/${p.id}/`,
      categories: [p.data.category, ...p.data.tags],
    })),
    customData: `<language>ja</language>`,
  });
}

カテゴリ別ページは src/pages/blog/category/[category].astro を作り、getStaticPathsCATEGORIES を列挙すれば 6 ページが自動で生まれます。タグ別ページも同じパターン(tag は collectTags(posts) でユニーク抽出)。sitemap は astro.config.mjssitemap() integration を入れておけば、これらを含めて sitemap-0.xml が自動出力されます。

落とし穴

  • slugid を混同する — Astro v5 の Content Collections では post.id が URL スラグ相当(ファイル名から派生)。別に slug フィールドを追加する必要は基本ありません
  • draft 記事の扱いgetCollection 時点でフィルタしないと、プレビューや個別記事に漏れる可能性がある。必ず 一箇所にまとめてフィルタする(本サイトは src/lib/blog.tsgetPublishedPosts に集約)
  • date のタイムゾーン — ローカル時刻で書くと JSON-LD や RSS で日付がずれる。UTC または ISO 文字列で書いて z.coerce.date() に解かせるのが安全
  • タグの URL エンコード — 日本語タグを使うと /blog/tag/お知らせ のようになる。encodeURIComponent でエスケープしてから href に入れる
  • MDX を使いたいとき@astrojs/mdx integration が別途必要。glob({ pattern: "**/*.{md,mdx}", ... }) を忘れるとインデックスされない
  • updatedDate 未設定の記事を更新 — JSON-LD の dateModifieddate のまま固定される。公開後に修正したら、updatedDate を入れる運用を決めておく

まとめ

Astro Content Collections は、CMS を立てるほどではない個人開発ブログに対して 必要十分なシンプルさを提供します。loader + schema + getCollection + render の 4 つを押さえれば、あとはコピペで十分実用的なブログが立ちます。

次回は Claude API のプロンプトキャッシュでコストを 1/10 にする を書く予定です。

参考

記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。

$cat./package.jsonjson
{
  "name": "astro-blog-minimal",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "@astrojs/rss": "^4.0.18",
    "@astrojs/sitemap": "^3.7.2",
    "astro": "^5.17.1"
  },
  "devDependencies": {
    "typescript": "^6.0.3"
  }
}
slugastro-content-collections-blogfiles9click a file in the tree to switch