Markdown ベースのブログを運用していると、frontmatter にルールを増やしたくなるタイミングが必ず来ます。「reviews カテゴリの記事には必ず広告ディスクロージャーを付ける」「特定の構造化データには本文側にも同じ内容を書く」など、書き手が忘れがちな運用ルールを、README に書くだけでは守れなくなります。
Astro Content Collections と Zod を組み合わせると、こうしたルールの大部分をビルド時に強制できます。.refine() で値同士の関係を縛り、z.object のネストで構造化データを型付けすれば、違反した frontmatter は astro build で落ちます。
この記事では、実際に運用しているブログサイトの schema 抜粋を例に、Zod の何をどう使うと運用ルールがビルドに落ちるかをコード中心で書きます。
最小構成: defineCollection + z.object
Astro 5 系の src/content.config.ts に以下のように書くと、ブログ記事の frontmatter が型付きで検証されます。
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(),
category: z.enum(["build", "reviews"]),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
affiliate: z.boolean().default(false),
}),
});
export const collections = { blog };
ポイントは 4 つです。
-
z.enumでカテゴリを 2 値に固定する。タイポした記事はビルドが落ちる -
z.coerce.dateで2026-05-23のような文字列をDateとして扱う -
.default(false)で省略時の値を定義する。書き忘れを許容したいフィールドに使う -
z.array(z.string())のような複合型もそのまま書ける
ここまでは Zod の基本的な使い方で、Astro 5 公式ドキュメントにも載っています。次の .refine() から運用ルール強制の本題に入ります。
.refine() で「値同士の関係」を縛る
2 つのフィールドが必ず連動する関係を強制したい場合、.refine() を schema の末尾に置きます。たとえばブログ記事で「category が reviews なら affiliate は必ず true、build なら必ず false」というルールを書くと以下になります。
const blog = defineCollection({
loader: glob({ /* ... */ }),
schema: z
.object({
title: z.string(),
category: z.enum(["build", "reviews"]),
affiliate: z.boolean().default(false),
// ... 他のフィールド
})
.refine((data) => (data.category === "reviews") === data.affiliate, {
message: "affiliate must be true iff category is 'reviews'",
path: ["affiliate"],
}),
});
両辺を === で比較しているので、どちらか一方だけが書き換わるとビルドが落ちます。category: reviews で affiliate: false のままだとアフィリエイトリンクの自動処理(rel="sponsored" の付与、ディスクロージャーの自動挿入)が走らないので、これは絶対に防ぎたい組み合わせです。
ビルドエラーの出力はこのような形になります。
[ContentEntryInvalidError] Content config error in `blog → 2026-05-...`:
affiliate must be true iff category is 'reviews'
at affiliate
message に書いた文字列がそのまま出るので、未来の自分にエラーの直し方が伝わるよう少し丁寧めに書いておくと運用が楽です。
.refine と .superRefine の使い分け
複数の独立した制約を 1 つの .object にかけたい場合や、エラーメッセージを場所ごとに変えたい場合は .superRefine() のほうが書きやすくなります。
.superRefine((data, ctx) => {
if (data.category === "reviews" && !data.affiliate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "reviews 記事は affiliate: true が必須です",
path: ["affiliate"],
});
}
if (data.draft && data.updatedDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "draft 記事には updatedDate を付けないでください",
path: ["updatedDate"],
});
}
})
縛りたい関係が 1 つだけなら .refine() で十分です。
構造化データを frontmatter で型付けする
JSON-LD の HowTo や FAQPage を出力するとき、ステップや質問のデータを frontmatter に置く と運用が楽になります。理由は次の通りです。
- 本文 MDX をパースして見出しから抽出する経路を作らずに済む
- frontmatter なので Zod で型検証が効く
- JSON-LD のジェネレータが frontmatter を信用して読める
schema 側はこのように書きます。
const blog = defineCollection({
loader: glob({ /* ... */ }),
schema: z.object({
title: z.string(),
// ...
howto: z
.object({
name: z.string().optional(),
description: z.string().optional(),
totalTime: z.string().optional(),
steps: z.array(
z.object({
name: z.string(),
text: z.string(),
image: z.string().optional(),
}),
),
})
.optional(),
faq: z
.array(
z.object({
question: z.string(),
answer: z.string(),
}),
)
.optional(),
}),
});
記事側 frontmatter は YAML でこう書きます。
---
title: "Astro Content Collections の運用 Tips"
faq:
- question: "Zod の .refine と .superRefine はどう使い分ける?"
answer: "値同士の関係を 1 つだけ縛るなら .refine() で十分です。..."
- question: "schema を後から変更したら既存記事は?"
answer: "ビルドが落ちます。必須フィールドを追加すると..."
---
steps 配列のオブジェクトに name が無い、faq に question だけで answer が無い、といった構造的な不備はビルドで落ちます。
schema で縛れない部分
Zod は frontmatter の中身しか見ません。本文 MDX の中身は検証範囲外です。
たとえば Google の検索品質ガイドラインには「JSON-LD だけ豪華で本文に書かれていない」構造化データを mismatch とみなす規定があり、frontmatter の FAQ question が本文に出てこないとリッチリザルトの表示資格が落ちます。これは Zod では検出できません。
別レイヤーとして、grep ベースの軽い validator を CI で走らせる方法が現実的です。簡略版は以下のような実装になります。
import { readFile } from "node:fs/promises";
import { parse as parseYaml } from "yaml";
const raw = await readFile(path, "utf8");
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
if (!m) process.exit(0);
const data = parseYaml(m[1]);
const body = m[2].replace(/\s+/g, " ").toLowerCase();
const mismatches = [];
if (Array.isArray(data.faq)) {
for (const [i, q] of data.faq.entries()) {
const needle = q.question.replace(/\s+/g, " ").toLowerCase();
if (!body.includes(needle)) {
mismatches.push(`faq[${i}].question not in body: "${q.question}"`);
}
}
}
if (mismatches.length) {
for (const e of mismatches) console.error(e);
process.exit(1);
}
文字列の存在チェックだけなので、答えの正しさや表現の自然さまでは見ません。そこは公開前のレビューで担保するレイヤーになります。
レイヤー分担の整理
運用ルールを 3 層に分けて、どこで何を担保するかを決めておくと迷いません。
| レイヤー | 落とせるタイミング | 守れること |
|---|---|---|
| Zod schema |
astro build 時 |
型 / 列挙 / 必須・任意 / 値同士の関係 |
| Lint スクリプト | pre-commit, CI | 禁止フレーズ / frontmatter と本文の文字列一致 |
| レビュー | 公開前チェック | 意味の妥当性 / 自動化できない判断 |
上のレイヤーで落とせるものは下に降ろさない、という方針にしておくと、新しい運用ルールが出てきたときに「これはどの層で守らせるか」だけ考えればよくなります。
実際に運用してみると、Zod の何を選ぶかより「どこまでを schema に寄せるか」のほうが効くと感じます。Aulvem 本家のブログでは、運用フローの背景や、schema 化しないと決めた境界線(disclosure の強度・AI 臭の総合判定など)まで含めて整理しています。
より詳しくは Aulvem の本家記事に書きました → Zod schema で運用ルールを強制する — Aulvem の Content Collections 設計