はじめに
初めまして、株式会社メンバーズの原です。
普段はフロントエンドエンジニアとして、Jamstack構成の開発を行っています。
今回は、マネジメントAPI(ベータ版)を使って、自動で型生成を行う仕組みをご紹介します。
microCMSのフィールド構成をAstro側でZodスキーマとして書き写している場合、フィールドを1つ増やすたびにmicroCMS側とAstro側の2箇所を直す必要があります。APIが増えればその対象も増えていきます。この作業を忘れてしまうと、ビルドは通るのに本番で値が出ないバグが発生してしまいます。
そこで活用したいのが、ベータ版として提供されているマネジメントAPIです。これは管理画面内の情報取得や操作を行えるAPIで、これを利用してCMS側のスキーマ情報を取得すれば、フロントエンド側の型を自動で生成できます。
これにより、フロント側で都度型を書き直す手間がなくなり、修正忘れによるバグも防げます。
※本記事では以下の環境で動作を確認しています。
- Astro 7.1.5(Astro 5 以降であれば同じ手順で動作します)
- Node.js 24(Node.js 20.6 以降が必要です)
- Zod 4系(Astroに同梱)
実装の背景
スキーマが二重管理になっている
microCMSとAstroを組み合わせる構成では、フィールド定義を両側に持つ必要があります。これまで携わった案件でも、この点はいつも同じでした。管理画面のAPIスキーマ設定と、Astro側のcontent.config.tsは同期されないいないため、片方を変えてももう片方は追従して変更されません。APIが増えれば増えるほどフロント側での定義箇所も増えるため、管理する場所が煩雑になります。また、フィールドを1つ足すたびに、microCMS側とAstro側の両方を直す必要があります。
さらに、microCMS側で任意に設定したフィールドは、値が空のときレスポンスに含まれません。そのためZodスキーマ側も.optional()にする必要がありますが、この対応は手作業では見落としやすく、付け忘れると型チェックを通過し実行時にundefinedになってしまいます。
この課題を解決するために、スキーマ情報をプログラムから取得してZodスキーマを自動生成する仕組みを作りました。本記事ではその方法を紹介します。
APIスキーマがプログラムから取得できるようになった
これまでも管理画面からJSONファイルをダウンロードすることはできましたが、手動なので自動生成の起点にはできませんでした。しかし、2025年9月に GET /api/v1/apis/{endpoint} が追加されたことで、プログラムから取得できるようになりました。このレスポンスには多くの情報が含まれますが、今回のような型生成に使うのは次の4つだけです。
fieldId:Zodのキー名になるkind:Zodの型を決めるrequired:.optional()を付けるかを決めるselectItems:z.enumの選択肢の材料になる
{
"apiFields": [
{
"fieldId": "title",
"name": "タイトル",
"kind": "text",
"required": true
},
{
"fieldId": "eyecatch",
"name": "アイキャッチ",
"kind": "media",
"required": false
},
{
"fieldId": "tags",
"name": "タグ",
"kind": "select",
"required": false,
"selectItems": [
{ "id": "57n7cwcI1t", "value": "更新情報" },
{ "id": "Lzq9rKoKi0", "value": "チュートリアル" },
{ "id": "nkyHY41vzG", "value": "お知らせ" }
],
"multipleSelect": false
}
],
"customFields": []
}※型生成に使わないキーは省略しています。
設計として決めた2つのこと
ここまでで、スキーマ情報が取得できることは分かりました。ただ、取得した情報をどう型に変えるかには選択肢があります。実装に入る前に、今回決めた2つの方針を説明します。
判断① TS型ではなくZodスキーマを生成する
.d.tsを直接生成する手もありますが、今回はZodを選んで実装しました。Content Collectionsのschemaはバリデーションと型生成を兼ねているので、Zodを作れば型がついてくるようになります。また、作るものが1つで済み、実データが定義とズレたときの検知もできるようになります。
判断② 実行時に組み立てるのではなく、コードとしてファイルに書き出す
最初は実行時にマネジメントAPIを叩いてZodスキーマを組み立てる実装にしていました。ところが型がunknownとなり補完が効かず、data.publishedDate.getFullYear()も書くことができませんでした。原因はTypeScriptの型がビルド前に確定するためで、実行時に組み立てたスキーマからは静的な型を導出できません。
そこで、今回はファイルとして書き出す方式に変えました。生成物が実在するソースコードなら、型は手書きと完全に同一になります。引き換えに、microCMS側を変更したら再生成が必要になります。
※Astro 6以降にはcreateSchema()があり動的生成のまま型を得られますが、TypeScriptの型定義も組み立てる必要になるため本記事では扱いません。
なお、本記事で扱うのは基本的なフィールドのみです。コンテンツ参照やカスタムフィールドは、スキーマ情報だけでは型が一意に決まらないため対象外としています。
作成する機能の概要
すでにmicroCMSとAstroで動いているサイトがあれば、この仕組みは後から追加できます。生成したファイルをimportするだけなので、既存のcontent.config.tsを大きく書き換える必要はありません。
導入後のcontent.config.tsは次のようになります。スキーマの定義がなくなり、生成されたファイルをimportするだけになっています。
import { defineCollection } from 'astro:content';
import { blogsSchema } from './generated/blogs-schema';
import { newsSchema } from './generated/news-schema';
import { authorsSchema } from './generated/authors-schema';
const blogs = defineCollection({
loader: microcmsLoader('blogs'),
schema: blogsSchema,
});
const news = defineCollection({
loader: microcmsLoader('news'),
schema: newsSchema,
});
const authors = defineCollection({
loader: microcmsLoader('authors'),
schema: authorsSchema,
});
export const collections = { blogs, news, authors };microcmsLoaderはコンテンツを取得する部分で、これは自動生成の対象ではありません。
この仕組みは3つのステップで動きます。まずスクリプトがマネジメントAPIからスキーマ情報を取得し、kindを見てZodのコードを組み立て、src/generated/にファイルとして書き出します。
詳しい実装は次の章で説明します。
導入にあたって必要な準備は、APIキーへの権限追加だけです。コンテンツAPIのGETに加えて、「マネジメントAPI(ベータ)」タブで「API情報の取得(一覧・詳細)」を有効にしてください。
このタブの切り替えは見落としやすく、この設定を忘れると次章のスクリプトが403で失敗してしまいます。

本記事では例として3つのAPI(blogs / news / authors)を使いますが、構成を再現する必要はありません。お手元のAPIに読み替えてください。

実装手順
ここから、scripts/gen-schema.mjsという1つのスクリプトに書いていきます。Astroのビルドとは独立して動かすもので、既存のプロジェクトにはscripts/ディレクトリを追加するだけで済みます。
スキーマ情報からZodのコードを組み立てる
取得したスキーマ情報をもとに、Zodスキーマのコードを組み立てていきます。
最初に1つ、注意しておきたい点があります。この変換関数が返すのは、Zodのオブジェクトではなく文字列です。
return 'z.string()';z.string()と書きたくなりますが、クォートで囲んでいます。最終的にやりたいのはファイルへの書き出しなので、必要なのは実行可能なZodオブジェクトではなく、ソースコードとしての文字列を返すようにしています。
変換のルールはkindの値ごとに決まります。
kind | 生成されるコード |
|---|---|
text / textArea | z.string() |
richEditorV2 | z.string() |
media | z.object({ url: z.string(), width: z.number(), height: z.number() }) |
date | z.coerce.date() |
select | z.array(z.enum([...])) |
number | z.number() |
これをそのまま関数に落とします。
function fieldToZodCode(field) {
switch (field.kind) {
case 'text':
case 'textArea':
return 'z.string()';
case 'richEditorV2':
return 'z.string()';
case 'media':
return 'z.object({ url: z.string(), width: z.number(), height: z.number() })';
case 'date':
return 'z.coerce.date()';
case 'select': {
const values = field.selectItems
.map((item) => JSON.stringify(item.value))
.join(', ');
return `z.array(z.enum([${values}]))`;
}
case 'number':
return 'z.number()';
default:
return 'z.unknown()';
}
}switchで分岐しているだけなので、難しいことはしていません。
注目して欲しいのはselectの部分です。セレクトフィールドは選択肢の一覧を持っているので、それをz.enumに展開しています。手作業でやる場合、管理画面を見ながら選択肢の文字列を写し、選択肢を追加したら型も直す必要がありますが、今回はその手間がなくなります。
JSON.stringifyを使っているのは、引用符のエスケープを任せるためです。このおかげで選択肢に記号が含まれていても壊れません。
なお、対応していない種類のフィールドはz.unknown()にしています。コンテンツ参照やカスタムフィールドがあってもスクリプトは止まらず、必要になったら.extend()で個別に補える形で対応します。
必須設定から.optional()を自動で付ける
スキーマ情報のrequiredを見て、falseなら.optional()を付けるだけです。
const suffix = field.required ? '' : '.optional()';
lines.push(` ${field.fieldId}: ${code}${suffix},`);たった1行ですが、これで任意フィールドすべてに.optional()が付きます。手作業なら1つずつ確認して付けていく作業で、しかも付け忘れてもエラーにならないため見落としに気づけません。
実データで確認したところ、microCMSは、値が空のフィールドをキーごと返さない挙動になっており、nullが入るわけではありません。そのため、本記事で扱う6種別については.nullable()ではなく.optional()が正解になります。
もう1つ、スキーマ情報には含まれないフィールドがあります。id、createdAt、updatedAt、publishedAt、revisedAtの5つは、microCMSが自動で付与する値なのでapiFieldsには現れません。これは自分で足す必要があります。
スキーマ情報から組み立てる前に、この5つを先に並べておきます。
const lines = [
' id: z.string(),',
' createdAt: z.coerce.date(),',
' updatedAt: z.coerce.date(),',
' publishedAt: z.coerce.date().optional(),',
' revisedAt: z.coerce.date().optional(),',
];publishedAtとrevisedAtに.optional()を付けているのは、下書き状態のコンテンツには存在しないためです。
複数のAPIに対応させる
ここまでで、1つのAPIからZodスキーマを生成できるようになりました。複数のAPIに対応させるのは簡単で、対象を配列にして回すだけです。
const ENDPOINTS = ['blogs', 'news', 'authors'];
for (const endpoint of ENDPOINTS) {
const apiSchema = await fetchApiSchema(endpoint);
const code = buildSchemaCode(endpoint, apiSchema);
await writeFile(`./src/generated/${endpoint}-schema.ts`, code, 'utf-8');
}fetchApiSchemaはマネジメントAPIを叩く関数、buildSchemaCodeはkindの変換と .optional()の付与をまとめたものです。
APIが増えたときに書き換えるのは、この配列にエンドポイント名を1つ足すだけです。
ターミナル上でnode --env-file=.env scripts/gen-schema.mjsを実行すると、ファイルが生成されます。

生成されたファイルを見てみます。authorsはblogsとフィールド名も構成も違うAPIですが、1文字も書いていません。
// このファイルは scripts/gen-schema.mjs が自動生成しています。
// 手動で編集しないでください。
// 再生成: node --env-file=.env scripts/gen-schema.mjs
import { z } from 'astro/zod';
export const authorsSchema = z.object({
id: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
publishedAt: z.coerce.date().optional(),
revisedAt: z.coerce.date().optional(),
name: z.string(),
profile: z.string().optional(),
avatar: z.object({ url: z.string(), width: z.number(), height: z.number() }).optional(),
});必須にしていないprofileとavatarには.optional()が付いています。手書きなら見落としがちな箇所です。
手作業でこれを書く場合、authorsだけで12行、blogsは20行を超えます。この方式なら、配列に1語足して、content.config.tsに4行書くだけで済みます。
実際に動かしてみよう
型が効くと、書けるコードが変わる
生成したスキーマをcontent.config.tsで読み込めば、あとは通常のContent Collectionsと同じように使えます。
一覧ページのコードを見てみます。
---
import { getCollection } from 'astro:content';
const blogs = await getCollection('blogs');
const sorted = blogs.sort(
// Date型なので getTime() で比較できる
(a, b) => b.data.publishedDate.getTime() - a.data.publishedDate.getTime()
);
---
<ul>
{sorted.map((blog) => (
<li>
{/* .optional() が付いているので分岐が必要 */}
{blog.data.eyecatch ? (
<img src={blog.data.eyecatch.url} alt="" />
) : (
<div class="no-image">No Image</div>
)}
<h2>{blog.data.title}</h2>
{/* 同上 */}
{blog.data.readingTime && <span>約{blog.data.readingTime}分</span>}
{/* 同上。オプショナルチェーンが必要 */}
{blog.data.tags?.map((tag) => <span class="tag">{tag}</span>)}
</li>
))}
</ul>特別なことはしていないコードですが、4箇所で型が効いています。
publishedDate.getTime()が書けているのは、z.coerce.date()でDateに変換されているからです。APIからは文字列で返ってきますが、Zodが変換してくれるので、そのまま比較してソートできます。
eyecatch ? ... : ...の分岐は、optional()が付いているフィールドなので、TypeScriptが「値がない可能性がある」と判断するため必要です。readingTimeの&&とtagsの?.も同じ理由です。
「設計として決めた2つのこと」で触れたとおり、実行時にスキーマを組み立てる方式では、これらはすべてunknownになって書けませんでした。ファイルとして書き出す方式に変えたことで、手書きのスキーマとまったく同じように扱えています。
表示を確認してみます。

アイキャッチ・読了時間を設定していない記事は、画像が「No Image」に置き換わり、読了時間も表示されていません。任意フィールドに.optional()が正しく付いている証拠です。
フィールド名を変えると、型エラーで気づける
最後に、この仕組みのもう一つの効果を確認します。
「設計として決めた2つのこと」で、Content Collectionsのschemaはバリデーションと型生成の両方を担うと書きました。ここまでは型生成の側面を見てきましたが、ここではもう一方が働きます。
管理画面でblogsのtitleをheadingにリネームし、スクリプトを再実行してからnpx astro checkを走らせてみます。
astro checkは、Astroプロジェクトの型チェックを行うコマンドです。.astroファイルの中で参照しているプロパティが型と合っているかを検証してくれます。

titleを参照している3箇所すべてがエラーになりました。注目したいのはエラーメッセージに出ている型で、
createdAt: Dateeyecatch?: ... | undefinedtags?: ("更新情報" | ... | "お知らせ")[]
と、生成したスキーマがそのまま型として効いていることが確認できます。
ただし注意点があります。この型エラーはastro checkでしか検出されません。Astroはビルド時に型チェックを行わないので、npm run buildは通ってしまいます。CIに組み込むなら、ビルド前にastro checkを走らせておく必要があります。
手書きのスキーマだったら、このエラー自体が出ません。管理画面でtitleを変えても、コード側は titleのままです。ビルドは通り、本番で値が表示されなくなって初めて気づくことになります。
「気づけない」から「気づける」に変わったことが、この仕組みを入れて一番良かった点です。型が自動で付くことや、書く量が減ることも利点ですが、実際に運用していて効いてくるのは検知のほうです。手作業では、直し忘れたことにすら気づけません。
注意点や推奨
運用に乗せるうえで、気をつけておきたい点とおすすめの設定を4つ挙げます。
変更したら再生成が必要
この仕組みは、生成した時点のスキーマを固定します。microCMS側でフィールドを追加したり必須設定を変えたりしても、スクリプトを実行するまでZodスキーマは古いままです。
忘れると型と実データがずれるので、package.jsonのprebuildに登録して、ビルド前に必ず走るようにしておくと安全です。
{
"scripts": {
"prebuild": "node --env-file=.env scripts/gen-schema.mjs",
"build": "astro build"
}
}ただしこの場合、ビルド環境にマネジメントAPIキーが必要になります。
型エラーはビルドでは検出されない
「フィールド名を変えると、型エラーで気づける」でも触れましたが、Astroはビルド時に型チェックを行いません。astro checkを実行しないと、フィールド名の変更に気づけないままビルドが通ります。CIを組んでいるなら、ビルド前にastro checkを挟んでおくと確実です。
生成したファイルはコミットする
src/generated/は自動生成物ですが、Gitに含めることをおすすめします。
ファイルがリポジトリにあれば、クローンした直後から型が効きますし、CIでスキーマを取得する必要もなくなります。マネジメントAPIキーを環境変数に登録せずに済むのは、運用上の利点です。
生成したタイミングでのスキーマがGitの履歴に残るので、いつフィールドが変わったのかを追えるようにもなります。
対応外のフィールドは型が付かない
コンテンツ参照やカスタムフィールドはz.unknown()になります。スクリプトは止まりませんが、型は付きません。必要になったらcontent.config.ts側で.extend()を使って補います。生成したファイルを直接編集すると次の生成で消えるので、拡張は必ず別の場所で行ってください。
const blogs = defineCollection({
loader: microcmsLoader('blogs'),
schema: blogsSchema.extend({
category: z.object({
id: z.string(),
name: z.string(),
}).nullable(),
}),
});ここで注意したいのが.nullable()です。コンテンツ参照フィールドは、値が設定されていないときnullが返ります。基本的なフィールドがキーごと消えるのとは挙動が違うため、.optional()では検証に通りません。
おわりに
microCMSのマネジメントAPIを使って、Content Collectionsの型を自動生成する方法を紹介しました。
本内容を一言でまとめると、スキーマの情報源をmicroCMS側に一本化した、ということになります。
管理画面とコードの2箇所にあった同じ情報が1箇所になり、型が自動で付くのも、フィールドの変更に気づけるようになったのも、すべて一本化した影響です。
- 新しいAPIを足すときの心理的な負担が減った。 以前は型を書く作業が発生するので後回しにしがちだったが、いまは配列に1語足すだけで済む。
- レビューで見るべき箇所が減った。 生成物は機械が作るので、人間がチェックするのは生成スクリプトだけになる。
- microCMS側の設定がそのまま仕様になった。 管理画面を見れば型が分かる状態になり、フロントとCMSで認識がずれなくなる。
今回は基本的なフィールドだけを対象にしましたが、コンテンツ参照やカスタムフィールドへの対応も、変換関数に分岐を足していけば同じ考え方で実現できます。
また、Astro 6以降で追加された createSchema()を使えば、ファイルを生成せずに動的なままで型を得ることもできます。スキーマを取得してZodのコードを組み立てる部分はフレームワークに依存しないので、Next.jsなど他の構成でも流用できると思います。
型定義の二重管理に心当たりのある方は、ぜひ試してみてください。