TechBlog一覧へ
Astro 2026年4月8日

Astro Content Collections + Zodで型安全なコンテンツ管理を実現した話

XECIN ZodTypeScriptフロントエンド

自分たちのコーポレートサイトをAstroで作り直すと決めたとき、最初に悩んだのがコンテンツ管理の方法でした。WordPressのときはエディタでポチポチ書けていたので、そこからMDXファイルに戻るのは正直ちょっと後退感があったんですよね。

ただ、プラグイン更新とアップデート地獄から逃げたい気持ちのほうが強くて、Git管理の静的サイトに振り切ることにしました。そこで採用したのが Astro Content Collections です。

最初に書いたスキーマはこれ

ざっくり言うと、src/content/ 配下のMarkdown/MDXをZodで型付けして扱える仕組みです。地味に聞こえるんですが、触ってみるとかなり便利でした。

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

const news = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string().max(100),
    summary: z.string().max(200),
    publishedAt: z.date(),
    status: z.enum(['draft', 'published']),
    tags: z.array(z.string()),
    author: z.string().default('XECIN'),
    aiGenerated: z.boolean().default(false),
  }),
});

export const collections = { news };

titlemax(100) を付けているのはデザイン上の都合で、長すぎるとカード表示が崩れるからです。最初はなくても良いかなと思っていたんですが、結局入れました。型で守れるものは全部型で守ったほうが幸せになれる、というのが最近の自分の持論なんですよね。

下書き管理でいきなりコケた話

正直なところ、最初に自分が考えた設計は雑でした。「draftのファイルはコミットしない。公開するときにコミットする」というやつです。

で、これが見事に破綻しました。

draft状態でレビューしたいのに、未コミットだとチームに共有できない。Git管理と言いつつGitに載らないファイルが発生していて、本末転倒だったんです。

そこで status を enum で持たせて、取得側でフィルタする方式に切り替えました。

// src/pages/news/index.astro
import { getCollection } from 'astro:content';

const allNews = await getCollection('news', ({ data }) => {
  return import.meta.env.PROD
    ? data.status === 'published'
    : true; // 開発/プレビュー環境では draft も表示
});

本番ビルドでは published のものだけを返し、開発環境では draft も見える。これで「PRプレビュー環境で下書きを確認する」運用が成立しました。

Zodで z.enum(['draft', 'published']) と縛っているので、stauts: "publised" みたいな二重タイポも即ビルドエラーで弾けます。これが地味にありがたい。以前typoを延々と見落とした記憶があるので、型で殴れるのは精神衛生上も良いですね。

MDXとReact Islandsの使い分けで少し悩んだ

もう一つ悩んだのがMDX内のインタラクティブ要素の扱いです。

公式ドキュメントには「MDXの中でReactコンポーネントが使えます」と書いてあって、自分も最初は何も考えずに client:load を付けまくっていました。でも実際やってみると、ニュース記事みたいな読み物ページにJSハイドレーションが必要な箇所ってほとんどないんですよね。

---
// ❌ 最初はこう書いていた
import NewsCard from '@/components/NewsCard.tsx';
---
<NewsCard client:load news={item} />

このコードは改善の余地があって、本当は NewsCard.astro で書き直すのがベストです。ただ既存のReact資産がそれなりにあったので一部そのままにしていて、いずれ全部Astroコンポーネントに寄せたいと思っています。ここは好みが分かれるところですが、client: が付いているコンポーネントを grep すればインタラクティブな箇所が一目で分かる、というメリットもあって今は妥協点として受け入れています。

ビルド時バリデーションがじわじわ効いてくる

Content Collectionsで一番嬉しかったのが、ビルド時にスキーマバリデーションが走ることです。

title を書き忘れれば astro build が落ちます。publishedAt を文字列で書いてもエラーになる。当たり前に聞こえるかもしれませんが、以前「本番で記事を公開したら見出しが空だった」みたいな事故を目撃したことがあったので、ビルド時に落ちてくれる安心感は段違いでした。

最近はこのリポジトリのコンテンツ更新をAIエージェント経由で流す運用になっているんですが、そこでもこのバリデーションが効いています。AIが生成したMDXがスキーマに違反すればCIで弾かれる。人間のレビュー負荷が減るし、AIが暴走してもスキーマで止められるという安心感は想像以上に大きいです。

今後こうしていきたい

今のところ大きな不満はないんですが、手を入れたい箇所はいくつかあります。

一つは publishedAt のタイムゾーン扱いで、今はただの z.date() なので、そのうちJST前提でパースするように寄せたい。もう一つは tags の値を z.enum() で縛るかどうか。自由度とタイポ防止のバランスで、まだ結論が出ていません。

触り始めの頃は「ただの型付きMarkdownだろ」と思っていたんですが、運用してみるとコンテンツ管理の土台として想像以上に強い。もっと良い使い方があったら教えてください、という気持ちで運用しています。