このサイト (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.xml → sitemap-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 };
設計の判断ポイント:
category は z.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 件抜けば関連記事も静的に出せます。
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 を作り、getStaticPaths で CATEGORIES を列挙すれば 6 ページが自動で生まれます。タグ別ページも同じパターン(tag は collectTags(posts) でユニーク抽出)。sitemap は astro.config.mjs に sitemap() integration を入れておけば、これらを含めて sitemap-0.xml が自動出力されます。
落とし穴
slug と id を混同する — Astro v5 の Content Collections では post.id が URL スラグ相当(ファイル名から派生)。別に slug フィールドを追加する必要は基本ありません
- draft 記事の扱い —
getCollection 時点でフィルタしないと、プレビューや個別記事に漏れる可能性がある。必ず 一箇所にまとめてフィルタする(本サイトは src/lib/blog.ts の getPublishedPosts に集約)
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 の dateModified が date のまま固定される。公開後に修正したら、updatedDate を入れる運用を決めておく
まとめ
Astro Content Collections は、CMS を立てるほどではない個人開発ブログに対して 必要十分なシンプルさを提供します。loader + schema + getCollection + render の 4 つを押さえれば、あとはコピペで十分実用的なブログが立ちます。
次回は Claude API のプロンプトキャッシュでコストを 1/10 にする を書く予定です。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─astro-content-collections-blog──────────files09──────────────────────●●●─┐$cat./package.jsonjson
# astro-blog-minimal
Astro v5 Content Collections + Content Layer API で組んだ、最小の技術ブログテンプレート。
[記事本文](https://r43lab.com/blog/astro-content-collections-blog) と対になる実動構成。
## Quick start
```bash
npm create astro@latest astro-blog-minimal -- --template minimal
cd astro-blog-minimal
npm install @astrojs/rss @astrojs/sitemap
# このリポジトリ配下のファイルを上書き or 参考にして差し替え
npm run dev
```
- `/blog` — 記事一覧
- `/blog/<slug>` — 個別記事
- `/blog/category/<category>` — カテゴリ別
- `/blog/tag/<tag>` — タグ別
- `/rss.xml` — RSS フィード
- `/sitemap-index.xml` — sitemap (build 時生成)
## 含まれるもの
- `astro.config.mjs` — sitemap integration
- `src/content.config.ts` — Content Collection 定義 (Zod schema)
- `src/content/blog/hello.md` — サンプル記事
- `src/pages/blog/index.astro` — 一覧
- `src/pages/blog/[slug].astro` — 個別記事
- `src/pages/rss.xml.js` — RSS エンドポイント
- `src/layouts/BlogPost.astro` — 記事レイアウト (TOC scroll-spy 付き)
## 依存バージョンについて
`package.json` は **記事執筆時点 (2026-04) のスナップショット** で、Astro v5.17 系を指定しています。2026-04 時点で Astro **v6** が安定リリース済みですが、本テンプレートは **Content Layer API**(`glob` loader + `render()` + `post.id`)を前提にしているため、そのまま v6 にも上げられます(v6 で削除されたのは legacy collections のみ)。
```bash
npm outdated
npm up # v6 に上げる場合もそのまま動くはず
npm run build # 上げた後に必ず検証
```
// @ts-check
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://example.com",
integrations: [sitemap()],
});
{
"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"
}
}
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
export const CATEGORIES = [
"cloudflare",
"claude-code",
"astro",
"typescript",
"devops",
"product",
] as const;
export type Category = (typeof CATEGORIES)[number];
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 };
---
title: "はじめての記事"
description: "astro-blog-minimal のサンプル記事です。frontmatter の全フィールドを使ったテンプレートとしても利用できます。"
date: 2026-04-26
tags: ["サンプル", "Astro"]
category: "astro"
author: "you"
---
## はじめに
このファイルを複製して、新しい記事を追加できます。`date` / `description` / `category` を書き換えるだけで、一覧・RSS・個別ページがすべて自動で追従します。
## セクションは h2 以上で
`BlogPost.astro` の TOC は `headings` から h2 / h3 を抜き出す想定です。h2 を 2 個以上入れてみてください。
### サブセクションも可
必要に応じて h3 以降も使えます。
## リンクやコード
通常の Markdown 機能はそのまま使えます。
```ts
// コードブロックは Shiki で自動ハイライトされる
export const greet = (name: string) => `Hello, ${name}!`;
```
## draft フラグ
公開前の記事は frontmatter に `draft: true` を足して、非公開にできます。
---
// BlogPost — 最小の記事レイアウト。
// TOC (scroll-spy) / breadcrumb / prev-next / related をまとめた参考実装。
// スタイルは骨組みだけなので、プロジェクト側のデザインシステムに合わせて差し替えてください。
import type { CollectionEntry } from "astro:content";
interface Heading { depth: number; slug: string; text: string; }
interface Props {
post: CollectionEntry<"blog">;
headings: Heading[];
prev: CollectionEntry<"blog"> | null;
next: CollectionEntry<"blog"> | null;
related: CollectionEntry<"blog">[];
}
const { post, headings, prev, next, related } = Astro.props;
const { data } = post;
const iso = data.date.toISOString().slice(0, 10);
const tocItems = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{data.title}</title>
<meta name="description" content={data.description} />
<link rel="canonical" href={Astro.url.href} />
</head>
<body>
<nav aria-label="breadcrumb">
<a href="/">home</a> / <a href="/blog">blog</a> / <span>{post.id}</span>
</nav>
<article>
<header>
<h1>{data.title}</h1>
<p>{data.description}</p>
<dl>
<dt>author</dt><dd>{data.author}</dd>
<dt>date</dt><dd><time datetime={data.date.toISOString()}>{iso}</time></dd>
<dt>category</dt><dd>{data.category}</dd>
{data.tags.length > 0 && (
<>
<dt>tags</dt>
<dd>
{data.tags.map((t) => (
<a href={`/blog/tag/${encodeURIComponent(t)}`}>#{t}</a>
))}
</dd>
</>
)}
</dl>
</header>
<div class="post-grid">
<div class="post-body">
<slot />
</div>
{tocItems.length > 0 && (
<aside class="post-toc" aria-label="目次">
<h2>Contents</h2>
<ol>
{tocItems.map((h) => (
<li class:list={[`depth-${h.depth}`]}>
<a href={`#${h.slug}`}>{h.text}</a>
</li>
))}
</ol>
</aside>
)}
</div>
<footer>
{(prev || next) && (
<nav aria-label="prev / next">
{prev && <a href={`/blog/${prev.id}`}>← {prev.data.title}</a>}
{next && <a href={`/blog/${next.id}`}>{next.data.title} →</a>}
</nav>
)}
{related.length > 0 && (
<section aria-label="related">
<h2>Related</h2>
<ul>
{related.map((p) => (
<li><a href={`/blog/${p.id}`}>{p.data.title}</a></li>
))}
</ul>
</section>
)}
</footer>
</article>
<script is:inline>
// TOC scroll-spy: 現在読んでいる見出しに data-active を付ける
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) return;
if (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));
}
</script>
</body>
</html>
---
import { getCollection, render } from "astro:content";
import BlogPost from "../../layouts/BlogPost.astro";
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);
// 前後記事 / 関連記事は getStaticPaths の取得結果から算出する
const all = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime()
);
const idx = all.findIndex((p) => p.id === post.id);
const prev = all[idx + 1] ?? null; // older
const next = all[idx - 1] ?? null; // newer
const related = all
.filter((p) => p.id !== post.id && p.data.category === post.data.category)
.slice(0, 3);
---
<BlogPost
post={post}
headings={headings}
prev={prev}
next={next}
related={related}
>
<Content />
</BlogPost>
---
import { getCollection } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime()
);
---
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Blog</title>
</head>
<body>
<main>
<h1>Blog</h1>
{posts.length === 0 ? (
<p>まだ記事がありません。</p>
) : (
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toISOString().slice(0, 10)}
</time>
{" — "}
<strong>{post.data.title}</strong>
</a>
</li>
))}
</ul>
)}
<p><a href="/rss.xml">RSS</a></p>
</main>
</body>
</html>
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: "astro-blog-minimal",
description: "Astro v5 Content Collections で組んだ最小ブログ",
site: context.site ?? "https://example.com/",
trailingSlash: false,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.date,
link: `/blog/${post.id}/`,
categories: [post.data.category, ...post.data.tags],
author: `${post.data.author}@example.com`,
})),
customData: `<language>ja</language>`,
});
}