本文へ移動
aapp-hacks
← 記事一覧へ
著者
app-hacks
公開日
更新日
確認日

Astro / TypeScript / Content Collections

Astro Content Collections入門:Zodを使った堅牢な型安全ブログの構築

Astro 7のContent CollectionsでMarkdownのFrontmatterを検証し、型安全に記事一覧を取得する実装を整理する。

AstroでMarkdown記事を管理するとき、ファイルを単純に読み込むだけではFrontmatterの欠落や型の揺れをビルド前に検出しにくい。たとえば公開日をある記事では文字列、別の記事ではDateとして扱うと、並び替えや日付表示の処理に条件分岐が増える。Content Collectionsは、コンテンツの読み込み方法とメタデータのスキーマを一か所に定義し、この揺れを境界で止める仕組みである。

対象バージョンと確認範囲

この記事は2026年9月11日に、このリポジトリへ導入されているAstro 7.3.2とNode.js 22.22.2で確認した。実際のsrc/content.config.tsでは、glob()ローダーでsrc/content/blog配下のMarkdownを読み込み、ZodスキーマでFrontmatterを検証している。外部CMSやMDXはこの確認範囲に含めていない。

コレクションを定義する

Astro 7では、ビルド時コレクションをsrc/content.config.tsで定義する。ローカルのMarkdownを記事ごとのファイルとして置く場合は、astro/loadersが提供するglob()ローダーを使う。スキーマにはastro/zodから読み込んだzを使用する。

import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({
    base: './src/content/blog',
    pattern: '**/*.md',
  }),
  schema: z.object({
    title: z.string().min(1),
    description: z.string().min(1),
    pubDate: z.coerce.date(),
    author: z.string().min(1).optional(),
    updatedDate: z.coerce.date().optional(),
    reviewedAt: z.coerce.date().optional(),
    tags: z.array(z.string().min(1)).min(1),
  }),
});

export const collections = { blog };

baseは記事を置くディレクトリ、patternは対象ファイルを示す。対象をMarkdownだけに限定するなら**/*.mdで十分である。将来MDXを導入したときだけ**/*.{md,mdx}へ広げればよく、使っていない形式を先回りして含める必要はない。

z.string().min(1)は空文字を拒否する。単なるz.string()では空のタイトルも有効になるため、一覧にリンクテキストが出ない記事を作れてしまう。tagsも最低一件を要求し、空文字のタグを拒否する。制約は実際の表示要件から逆算して置くことが重要である。

pubDateにはz.coerce.date()を指定している。MarkdownのFrontmatterに書いた日付はパーサーの解釈を経るが、この指定によって最終的なコレクションデータはDateとして扱える。ページ側で毎回new Date()を呼び、変換結果を疑う必要がなくなる。

Frontmatterをスキーマに合わせる

記事側にはスキーマで必須とした四項目を書く。

---
title: "Astroで型安全な記事管理を始める"
description: "Content Collectionsの最小構成を解説する。"
pubDate: 2026-09-01
author: "app-hacks"
updatedDate: 2026-09-11
reviewedAt: 2026-09-11
tags: ["Astro", "TypeScript"]
---

項目名は完全一致が前提である。pubDatepublishedAtと書き換えたり、tagsをカンマ区切りの単一文字列にしたりするとスキーマを通らない。スキーマエラーは不便な制約ではなく、壊れた記事が公開物へ混ざるのを防ぐ契約である。項目を追加したい場合も、まず全記事で本当に必要かを確認する。任意項目ならz.string().optional()のように明示し、既存記事を一斉に壊さない設計にする。

getCollectionで一覧を取得する

ページではgetCollection()を使って登録済みコレクションを読む。返り値の各要素にはiddata、本文情報があり、dataの型はスキーマから推論される。

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

const posts = (await getCollection('blog')).sort(
  (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---

<ul>
  {posts.map((post) => (
    <li>
      <a href={`/blog/${post.id}/`}>{post.data.title}</a>
      <time datetime={post.data.pubDate.toISOString()}>
        {post.data.pubDate.toLocaleDateString('ja-JP')}
      </time>
    </li>
  ))}
</ul>

ここではpubDateDateと推論されるため、valueOf()toISOString()を安全に呼べる。存在しないpost.data.authorへアクセスすればTypeScriptが指摘する。型を手書きした配列へMarkdownを無理に当てはめる場合と違い、スキーマが実データの検証とコード上の型定義を兼ねる点が大きい。

絞り込み条件もgetCollection()の第二引数に置ける。下書き項目をスキーマへ追加したプロジェクトなら、getCollection('blog', ({ data }) => !data.draft)のように公開対象だけを読み込める。ただし、この例のスキーマにはdraftが存在しない。記事コードは実際のスキーマに存在する項目だけを前提にするべきである。

詳細ページを静的生成する

各記事のURLをビルド時に作るには、動的ルートのgetStaticPaths()で記事とパスを対応させる。本文のレンダリングはrender()へエントリーを渡して行う。

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

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map((post) => ({
    params: { slug: post.id },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

この構成では、新しいMarkdownを追加するとローダーがエントリーを検出し、ビルドがFrontmatterを検証し、動的ルートがHTMLを生成する。ファイル名から得られるidをURLへ使うため、別のslugフィールドを重複管理しなくてよい。

検証を公開手順へ組み込む

スキーマはビルド時に初めて価値を発揮する。記事を追加したら開発画面だけで終えず、npm run buildを実行する。必須項目の欠落、日付変換の失敗、テンプレート側の型不整合を公開前にまとめて検出できる。CIでも同じコマンドを実行すれば、ローカルとデプロイ環境で検証手順を揃えられる。

Content Collectionsの設計で守るべき境界は明快である。Markdownは文章とFrontmatterを持ち、スキーマは入力を検証し、ページは検証済みデータだけを表示する。役割を混ぜずに保つことで、記事数が増えても条件分岐と型アサーションを増やさずに運用できる。

確認手順と失敗しやすい点

この構成では、プロジェクト直下でnvm useを実行してからnpm run buildを実行する。ビルドはMarkdownの読み込み、スキーマ検証、記事ページの静的生成までを通して確認する手順になる。

既存記事を壊さずに項目を増やすため、著者・更新日・確認日は任意項目として定義している。これらを必須に変更した場合、値を追加していない記事はビルド時に検証エラーになる。反対に、tagsを文字列一つで書くなど、配列を前提とした項目の形を変えるとページ側のmap()join()と整合しない。スキーマと表示コードを同時に確認することが必要である。

公式資料と関連記事