2026-05-14
Astro Content Collectionで型安全な静的サイト
AstroのContent CollectionとZodスキーマを組み合わせると、MarkdownフロントマターをTypeScriptで型安全に管理できます。実際のサイト構築例を交えて設計パターンを解説します。
この記事の要点
- Content CollectionはZodスキーマでフロントマターを型安全に定義できる
- src/content.config.tsでスキーマを定義するとIDEの補完と型チェックが動く
- getCollection()やgetEntry()でデータを取得でき、型が自動的に付く
- フロントマターのバリデーションはビルド時とastro check時に実行される
- ツールとブログの複数コレクションをsrc/content.config.tsで一元管理できる
Markdownのフロントマターを型安全に管理したいとします。そんなときAstro Content Collectionが役立ちます。Zodスキーマで型を定義すると、フィールド名の揺れや書式ミスをビルド時に検出できます。
Content Collectionとは
AstroのContent Collectionはsrc/content/配下のファイル群をまとめて扱う機能です。スキーマ付きのデータソースとして管理します。Markdown・MDX・JSONに対応し、ファイル数が増えても型の一貫性を保てます。
src/
content/
blog/
first-post.md
second-post.md
tools/
char-counter.md
json-formatter.md
content.config.ts ← スキーマ定義
従来はimport.meta.globでファイルを取得して、型をanyや手書きインターフェースで扱うことが多くありました。スキーマ変更の波及範囲を把握しにくい状況でした。Content Collectionを使うと、Zodスキーマが型の単一の信頼できる情報源になります。
スキーマ定義の基本
src/content.config.tsでコレクションを定義します。
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const CATEGORIES = ['テキスト', '変換', '計算', '開発', '画像', '生活'] as const;
const toolSchema = z.object({
title: z.string().min(5).max(30),
description: z.string().max(160),
category: z.enum(CATEGORIES),
keywords: z.array(z.string()).min(5),
publishedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
updatedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
whatIs: z.string().min(200),
usageSteps: z.array(z.string().min(4)).min(3),
useCases: z.array(z.object({
title: z.string().min(4),
body: z.string().min(40),
})).min(5),
techExplanation: z.string().min(150),
relatedTools: z.array(z.string()).optional(),
});
const tools = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/tools' }),
schema: toolSchema,
});
export const collections = { tools };
z.enum(CATEGORIES)のように、as constのタプルからenumを作ると便利です。フロントマターにcategory: "その他"と書いたとき、ビルド時エラーが発生します。文字列の制約を型レベルで強制できるのが最大の強みです。
ローダーの種類
globローダー(ファイルベース)
最も一般的なローダーです。patternでglob、baseでルートディレクトリを指定します。
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: blogSchema,
});
ファイル名がslug(post.id)になります。ネストしたディレクトリ構造にも対応しています。blog/2026/first-post.mdならidは2026/first-postです。
カスタムローダー(外部データソース)
Astro 5からコレクションのloaderをカスタム関数にできます。CMSのAPIやデータベースからデータを取得して、コレクションとして扱えます。
const cms = defineCollection({
loader: async () => {
const res = await fetch('https://api.cms.example/posts');
const posts = await res.json();
return posts.map(post => ({
id: post.slug,
...post,
}));
},
schema: postSchema,
});
静的ホスティングでも外部CMSとの連携が実現します。
コレクションデータの取得
getCollectionで全件取得
import { getCollection } from 'astro:content';
const allTools = await getCollection('tools');
// allTools は { id: string, data: ToolData, body: string }[]
// data の型は toolSchema から自動推論される
フィルタリングは第2引数のコールバックで行います。
const publishedPosts = await getCollection('blog', ({ data }) =>
!import.meta.env.DEV && data.publishedDate <= new Date().toISOString().slice(0, 10)
);
getEntryで1件取得
const entry = await getEntry('tools', 'char-counter');
// entry.data.title, entry.data.category などが型付きで参照できる
getEntryは存在しないIDを渡すとundefinedを返すため、404ハンドリングと組み合わせます。
動的ルーティングとの組み合わせ
[slug].astroでContent Collectionを使う標準パターンです。
---
// src/pages/tools/[slug].astro
import { getCollection, getEntry } from 'astro:content';
export async function getStaticPaths() {
const tools = await getCollection('tools');
return tools.map(tool => ({
params: { slug: tool.id },
props: { tool },
}));
}
const { tool } = Astro.props;
// tool.data.title, tool.data.category etc. が型付き
---
<h1>{tool.data.pageHeading}</h1>
getStaticPathsで全コレクションエントリを返すと、Astroがビルド時にすべてのツールページを静的生成します。tool.dataの型はスキーマから自動推論されるため、存在しないフィールドを参照するとTypeScriptエラーが出ます。
refineで複合バリデーション
Zodのrefine / superRefineを使うと、複数フィールドを組み合わせた検証ができます。
const blogSchema = z.object({
publishedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
publishedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).optional(),
updatedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
.refine(
(data) => !data.publishedAt || data.publishedAt.startsWith(data.publishedDate),
{
message: 'publishedAt の日付部分は publishedDate と一致させてください',
path: ['publishedAt'],
}
)
.refine(
(data) => !data.updatedDate || data.updatedDate >= data.publishedDate,
{
message: 'updatedDate は publishedDate 以降の日付にしてください',
path: ['updatedDate'],
}
);
updatedDateがpublishedDateより古い場合にエラーを出せます。publishedAtの日付部分がpublishedDateと一致しないときも検出できます。複合ルールをスキーマ定義に組み込めるのがrefineの利点です。
型のエクスポートと再利用
z.inferでスキーマから型を抽出し、コンポーネントやユーティリティ関数で再利用できます。
export const toolSchema = z.object({ /* ... */ });
export type ToolData = z.infer<typeof toolSchema>;
// コンポーネント側
import type { ToolData } from '../content.config';
interface Props {
tool: ToolData;
}
スキーマが型定義と実行時バリデーションを兼ねるため、型定義ファイルを別途作成する必要がありません。フィールドを追加・削除したときも1箇所の変更でIDEが全影響箇所を検出します。
ビルド時の型チェックフロー
Content Collectionを使ったサイトの品質チェックは以下の順で行います。
# 1. Zodバリデーション + 静的HTML生成
npm run build
# 2. TypeScript / .astro ファイルの型チェック
npx astro check
# 3. ユニットテストでコンテンツ構造を追加検証
npx vitest run
astro buildはZodスキーマのバリデーションエラーを表示してビルドを中断します。astro checkはTypeScriptの型エラーを検出します。両方をCIで走らせると、フロントマターの不正値がmainブランチに入り込むのを防げます。
てもとツールでの実践
てもとツールでは40以上のツールと10以上のブログ記事をContent Collectionで管理しています。
src/content/tools/{slug}.mdはツールの解説やメタ情報を持ちます。JSON-LDデータも構造化フィールドで管理しています。ToolExplanation.astroコンポーネントがそれをHTMLに変換します。
フロントマターに本文の見出しは書きません。whatIs / useCases[] / techExplanation のような構造化フィールドに分割して管理します。コンポーネントが描画ロジックを担うため、コンテンツ側は純粋なデータのみを持ちます。
まとめ
AstroのContent CollectionとZodを組み合わせることで、以下のメリットが得られます。
- 型安全: フロントマターのフィールド名・型・値域をコンパイル時に検証
- 自動補完: IDEが
data.以降のフィールドを候補として表示 - 一元管理: スキーマが型定義・バリデーション・ドキュメントを兼ねる
- リファクタリング安全: フィールド名変更の影響が型エラーとして全件列挙される
Markdownファイルが数件のうちはフリースタイルでも問題ありません。ツールやブログ記事が増えるにつれてスキーマの恩恵が大きくなります。最初からContent Collectionを使っておくことをおすすめします。