本文へスキップ
T2R tech2rich.com
Web開発 🔤 TypeScript 📚 Astro実践 第1回 #Astro #TypeScript #Zod #静的サイト #Markdown

【Astro実践 第1回】Content Collections × Zod でフロントマターを型安全にする — 記事60本でも破綻しない設計

Astro の Content Collections と Zod を組み合わせると、記事のフロントマターがビルド時に検証され、書き間違いがそのままビルドエラーになります。実際に60本以上の記事を運用しているサイトの config.ts を題材に、型安全な設計の勘所を解説します。

📅 公開: 2026.08.19 ⏱ 読了 約10分 ✍ 管理人

「記事を追加したら、なぜかカードのタグだけ表示されない」

Markdown で記事を管理していると、いつか必ずこれが起きます。原因はたいてい tags: "入門" と書くべきところを tag: 入門 と書いていた、というような フロントマターの些細な打ち間違いです。しかも静的サイトジェネレータの多くは、こういうミスを黙って無視してビルドを完走させてしまいます。

Astro の Content CollectionsZod を組み合わせると、この「黙って壊れる」がなくなります。書き間違えた瞬間に、キー名を名指ししてビルドが落ちるようになるからです。

このシリーズでは、実際に記事60本以上を運用しているこのサイト(tech2rich)の実装を題材に、Astro で長く運用できる構成の作り方を解説していきます。第1回は土台となるスキーマ設計です。

先輩

この記事のゴール

Content Collections と Zod スキーマの書き方を理解し、フロントマターのミスがビルド時に必ず検出される状態を自分のサイトで作れるようになることです。記事が増えても破綻しない設計の考え方も扱います。

Content Collections の最小構成

Content Collections は、src/content/ 以下の Markdown を型付きのデータとして扱う仕組みです。定義はたった1ファイル、src/content/config.ts に書きます。

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

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    publishDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

これで src/content/blog/*.md のフロントマターが検証対象になります。ページ側では、こう取り出します。

---
import { getCollection } from 'astro:content';

const posts = await getCollection('blog');
// posts[0].data.publishDate は Date 型として補完が効く
---
{posts.map((p) => <a href={`/blog/${p.slug}/`}>{p.data.title}</a>)}

ポイントは z.coerce.date() です。YAML には 2026-08-19 と文字列で書きますが、coerce を付けておくと取り出す時点で Date オブジェクトになっています。日付ソートのたびに new Date() で包む必要がなくなります。

補足:Astro 5 では新しい Content Layer API(loader: glob() を使う書き方)も選べます。ただし type: 'content' の書き方も 5.x でそのまま動作します(このサイトは Astro 5.18 でこの構成のまま運用しています)。移行は急がず、新規に作るコレクションから試すのが安全です。

Zod スキーマは「書いた瞬間に効く」

Zod の本当の価値は、型が付くことよりも バリデーションが実行されることです。

たとえば meta description は、長すぎると検索結果で途中から切られます。これをルール化してしまいます。

description: z.string().min(40).max(160),

こう書いておくと、短すぎる description を書いた瞬間にビルドが落ちます。

[InvalidContentEntryFrontmatterError] blog → my-post
  description: String must contain at least 40 character(s)

どのファイルの、どのキーが、なぜ駄目なのかが全部出ます。「あとでチェックしよう」と思って忘れる、が構造的に起きなくなるのが効きます。

同じ発想で、選択肢が決まっているものは必ず enum にします。

category: z.enum(['programming', 'web-dev', 'infra', 'ai-ml', 'tool', 'other']),
language: z.enum(['java', 'typescript', 'css', 'python', 'other']).optional(),

category: "webdev"(ハイフン忘れ)のようなミスが、カテゴリページから記事が消える形ではなく、ビルドエラーとして出てきます。

書き方ミスしたときに何が起きるか
素の Markdown黙って無視される。公開後に気づく
z.string()型は付くが、値の間違いは通る
z.enum([...])ビルドが落ちる。公開できない

全セクションを optional にすると、レイアウトが1枚で済む

ここからが設計の話です。

このサイトのアフィリエイト記事には、比較表・料金カード・ランキング・口コミ・FAQ といったセクションがあります。素直に作ると「レビュー用テンプレート」「ランキング用テンプレート」…とレイアウトが増えていきます。

代わりに採ったのが、セクションを全部 optional にして、キーがある時だけ描画するという方針です。

const articles = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    // ここから下は全部 optional
    comparison: z
      .object({
        title: z.string().default('比較表'),
        columns: z.array(z.string()),
        rows: z.array(z.array(z.string())),
      })
      .optional(),
    faq: z
      .object({
        title: z.string().default('よくある質問'),
        items: z.array(z.object({ q: z.string(), a: z.string() })),
      })
      .optional(),
  }),
});

レイアウト側は、存在チェックだけして並べます。

---
const { data } = entry;
---
{data.comparison && <ComparisonTable {...data.comparison} />}
{data.faq && <FAQ {...data.faq} />}

この形にすると、記事の書き手は 「必要なセクションのキーを書く/書かない」だけでページ構成を決められます。レイアウトは1枚のまま、レビューにも比較記事にも解約手続きの記事にも使えます。

.default('比較表') も地味に効きます。省略された時の値がスキーマ側に集約されるので、コンポーネントごとに title ?? '比較表' を書き散らさずに済みます。

image() で画像の参照切れをビルドエラーにする

画像は「参照先が消えていても、静かに壊れたまま公開される」典型です。Astro のスキーマヘルパー image() を使うと、これもビルドエラーにできます。

schema: ({ image }) =>
  z.object({
    title: z.string(),
    heroImage: image().optional(),
    heroImageAlt: z.string().optional(),
  }),

schema関数形式にして { image } を受け取るのがポイントです。Markdown 側では相対パスで書きます。

heroImage: "./_assets/my-post/hero.png"

存在しないファイルを指すと、ビルドがその場で落ちます。さらに、<Image> コンポーネントに渡せば WebP 変換とサイズ属性の付与まで自動でやってくれます。

---
import { Image } from 'astro:assets';
const { heroImage, heroImageAlt } = Astro.props.data;
---
{heroImage && <Image src={heroImage} alt={heroImageAlt ?? ''} widths={[400, 800]} />}

注意点として、この検査があるので画像を先に置かないと記事が書けません。運用では、記事の骨組みを作る段階で既存の画像を仮でコピーしておき、あとから本番画像で上書きする、という手順にしています。

enum を「単一の真実の源」にするときの落とし穴

z.enum() は便利ですが、値の一覧が config.ts の中に閉じてしまうという副作用があります。カテゴリの日本語ラベルや、管理画面のプルダウンでも同じ一覧が必要になるからです。

このサイトでは、キーの定義は config.ts に、表示ラベルは別ファイルに置いています。

// src/utils/seo.ts
export const CATEGORY_LABELS: Record<string, string> = {
  'web-dev': 'Web開発',
  programming: 'プログラミング',
  infra: 'インフラ',
};

この構成だと、カテゴリを1つ増やすのに config.ts の enum と CATEGORY_LABELS の両方を触る必要があります。片方だけ直すと「ビルドは通るがラベルが出ない(キーがそのまま表示される)」という中途半端な壊れ方をします。

対策はシンプルで、ラベル定義のほうを起点にして enum を導出することです。

// ラベル定義を先に書く
export const CATEGORIES = {
  'web-dev': 'Web開発',
  programming: 'プログラミング',
} as const;

// enum はここから作る
const categoryKeys = Object.keys(CATEGORIES) as [string, ...string[]];
category: z.enum(categoryKeys),

同期漏れが原理的に起きなくなります。ただし config.ts から外部ファイルを import する形になるので、循環参照だけは避けてください(ラベル側から astro:content を import しないこと)。

draft フラグで下書きを本番から外す

最後に、運用でほぼ必ず欲しくなる下書き機能です。スキーマに1行足します。

draft: z.boolean().default(false),

そして記事一覧を取るところで、開発時だけ通すフィルタを書きます。

const posts = await getCollection(
  'blog',
  ({ data }) => import.meta.env.DEV || !data.draft,
);

import.meta.env.DEVastro dev の時だけ true になります。つまり、

という挙動になります。draft: true の記事は静的ファイルとして出力されないので、URL を直接叩かれても存在しません。

⚠️ このフィルタは記事詳細ページだけでなく、一覧・カテゴリ別・サイトマップなど、そのコレクションを取得している全箇所に入れる必要があります。1か所忘れると、一覧に載っているのにクリックすると404、という状態になります。

つまずいたポイント3つ

実際に運用して踏んだものを挙げておきます。

1. slug はスキーマに書けない

slug は Astro が予約しているため、フロントマターに独自の slug を定義できません。URL を上書きしたい場合は別名にします。

urlSlug: z.string().optional(),

参照側は entry.data.urlSlug ?? entry.slug と書いてフォールバックさせます。

2. UTF-8 BOM 付きファイルはフロントマターを自前で読むと詰まる

Astro 本体は BOM を処理してくれますが、ビルドスクリプトなどで自分で Markdown を読んで正規表現でフロントマターを抜く場合、先頭の BOM のせいで /^---/ にマッチしません。読み込み直後に剥がすのが確実です。

const raw = fs.readFileSync(file, 'utf8').replace(/^/, '');

3. フロントマターを直したのに古いエラーが出る

Astro はコレクションの内容をキャッシュします。スキーマやフロントマターを編集したあとに "Duplicate id" のような身に覚えのない警告が出たら、キャッシュを消して作り直せば消えます。

rm -rf .astro && npm run build

やってみよう

いま運用中のサイトがあるなら、既存のスキーマに1つだけ制約を足してビルドしてみてください。おすすめは description の文字数制限です。

description: z.string().min(40).max(160),

おそらく数本は落ちます。それが「今まで黙って公開されていた不備」です。落ちた記事を直すところまでが、この仕組みを入れる価値のいちばん分かりやすい部分になります。

まとめ

スキーマは「書き手を縛るもの」に見えますが、実際に効いてくるのは半年後に自分がミスしたときです。記事が増えるほど、この投資は回収されます。

次回は、この上に乗る SEO まわりの自動生成(JSON-LD 構造化データと、sitemap の lastmod を正確に出す方法)を扱います。

関連の技術記事

コメント

コメントを読み込み中…

コメントを投稿する

投稿いただいたコメントは、確認のうえ掲載します(すぐには表示されません)。 メールアドレスの入力は不要です。