てもとツール

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,
});

ファイル名がslugpost.id)になります。ネストしたディレクトリ構造にも対応しています。blog/2026/first-post.mdならid2026/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'],
  }
);

updatedDatepublishedDateより古い場合にエラーを出せます。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を使っておくことをおすすめします。