Astro Content Collectionsの使い方。スキーマ定義から型の使い回しまで
// 記事のポイント
- Content Collectionsは、記事をひとまとまりのデータとして扱い、決めた形式に沿っているかをビルド時にチェックする仕組み
- glob()ローダーで記事を集め、Zodスキーマでカテゴリの候補・日付・画像の実在まで縛れる
- getCollection()の取得順は保証されないので、並び替えは自分で書く必要がある
- スキーマは実行時のチェックだけでなく、TypeScriptの型の情報源としても使い回せる
今日は、このブログを動かしているAstroの「Content Collections」を紹介するよ〜。
もともと基本編と型編で2本に分けて書いてたんだけど、続けて読んだ方が絶対に分かりやすいから、1本にまとめ直したんだ。
パパ、これくしょんって、こめのおやつコレクションみたいなもの?
種類ごとにきれいに並べるの?
かなり近い(笑)
ばらばらに置いてあるものを、同じルールでそろえて管理する仕組み、っていうイメージで合ってるよ。
Content Collectionsとは
Markdownの記事が増えてくると、タイトルや公開日、カテゴリの書き忘れが気になってくる。Content Collectionsを使うと、記事をひとまとまりのデータとして扱いながら、決めた形式に沿っているかをビルド時にチェックできる。
Content Collectionsは「同じ形を持つコンテンツの集まり」のこと。ブログ記事なら、どの記事にもタイトル・説明文・公開日・カテゴリがある、という共通ルールを作れる。ローカルのMarkdownだけでなく、JSONやYAML、CMSやAPIから取得したデータも同じ仕組みで扱える。
記事ごとに書き方がばらばらになっちゃうのを、先に防げるんだね〜。
こめちゃんのおやつも、名前と個数をそろえて管理したら分かりやすそう。
こめのおやつは、ぜんぶ「おいしい」でいいと思う!
それだと在庫が分からなくなっちゃうよ(笑) このブログだと、そのルールを
src/content.config.tsっていう1つのファイルにまとめて書いてるんだ。ローダーとスキーマを定義する
Content Collectionsの定義は
src/content.config.tsに書く。「どこから記事を集めるか」を決めるのがローダー、「集めた記事がどんな形か」を決めるのがスキーマだ。
このブログではglob()ローダーでsrc/content/blog配下のMarkdownを読み込み、スキーマはZodというバリデーションライブラリで組み立てている。
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { categorySlugs } from './data/categories';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: ({ image }) => z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
category: z.enum(categorySlugs),
tags: z.array(z.string()).optional(),
// 実際にはサムネイル・会話データなどの項目がこの後に続く
}),
});
export const collections = { blog };パパ、なんだか呪文みたいだね〜。
こめには読めないよ!
最初はそう見えるよね(笑)
でも、読むところは2つだけなんだ。
loaderが「どこから集めるか」で、schemaが「集めたものがどんな形か」。この2つさえ押さえれば、あとは項目が並んでいるだけだよ。titleは文字で、pubDateは日付で…って、1個ずつ宣言していく感じなんだね〜。カテゴリと日付は、書き間違えたらビルドで止める
Zodの便利なところは、「文字列であること」よりもう一歩踏み込んだ制約を書けることだ。
たとえばカテゴリは
z.enum(categorySlugs)にしてある。categorySlugsはsrc/data/categories.tsで定義しているカテゴリのslug一覧で、ここに無い値を書くとビルドが失敗する。
category: z.enum(categorySlugs), // daily / money / travel ... 以外は弾く
pubDate: z.coerce.date(), // 文字列をDateへ変換。日付にならない値はエラー
tags: z.array(z.string()).optional(),
needsReview: z.boolean().optional().default(false),
z.coerce.date()は、Markdownに書いた2026-07-27のような文字列をDateオブジェクトへ変換してくれる。日付として解釈できない値を書いた場合はその場でエラーになるので、並び替えたときに初めて順番のおかしさに気づく、ということがなくなる。.optional()を付けた項目は省略でき、.default(false)まで付ければ、書かなかったときの値まで1か所で決められる。実際、僕もカテゴリ名を1文字打ち間違えたことに、ビルドエラーで気づいたことがあるんだ。
これが素のMarkdownだったら、カテゴリページから記事が1本消えてることに、しばらく気づけなかったと思うよ。
まちがえたら、ちゃんと怒ってくれるの?
こめ、怒られるのはちょっとやだな〜。
公開しちゃう前に教えてくれるんだから、やさしい方だと思うよ〜。
画像はimage()で受け取る
スキーマの定義が
({ image }) => z.object({...})という形になっているのは、Astroが渡してくれるimage()ヘルパーを使うためだ。
thumbnail: z
.object({
src: image(),
alt: z.string(),
})
.optional(),
image()で受け取ったパスは、ただの文字列ではなくAstroの画像アセットとして扱われる。存在しないファイルを指していればビルドで止まり、実在すれば最適化やサイズ指定の対象になる。記事側では../../assets/blog/<slug>/thumbnail.pngのように、Markdownファイルからの相対パスで書く。画像のパスを打ち間違えたまま公開しちゃう、みたいな事故も防げるんだね〜。
そうそう。画像のリンク切れは、公開してから気づくと地味に恥ずかしいからね(笑)
altを必須にしてあるのもポイントで、代替テキストを書き忘れた記事はそもそもビルドが通らないようにしてるんだ。getCollectionで記事を取り出す
定義したコレクションは
getCollection('blog')でまとめて取得できる。返ってくる各記事には、ファイル名から作られるidと、スキーマに沿ったdataが入っている。
注意したいのは、取得順が保証されていないこと。日付順に見せたいなら自分で並べ替える必要がある。このブログでは取得と並べ替えをまとめた関数を1つ用意して、一覧ページから共通で呼んでいる。
import { getCollection, type CollectionEntry } from 'astro:content';
export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
const all = await getCollection('blog');
return all.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}じゃあ、こめの記事をいちばん新しくしたら、いちばん上に出るってこと?
その通り!
pubDateが新しい順に並べてるからね。
こうやって1か所にまとめておくと、トップページでもカテゴリページでも、同じ並び順をそのまま使い回せるんだ。スキーマは、TypeScriptの型の情報源にもなる
ここからが、Content Collectionsを使っていて一番おいしいと感じている部分だ。
スキーマで決めた形は、実行時のチェックに使われるだけでなく、TypeScriptの型としても取り出せる。
CollectionEntry<'blog'>['data']と書けば記事データ全体の型が手に入り、そこからさらにキーをたどれば一部だけの型も取り出せる。
import type { CollectionEntry } from 'astro:content';
// 会話1ターン分の型をスキーマから導出する
export type ConversationTurn = CollectionEntry<'blog'>['data']['conversation'][number];
// 任意項目は、値が入っている場合の形だけをNonNullableで取り出す
export type TurnImage = NonNullable<ConversationTurn['image']>;
export type TurnLink = NonNullable<ConversationTurn['link']>;最初の頃はこれを知らなくて、記事を表示するコンポーネントごとに
interface Turnみたいな型を自分で書いてたんだよね。
似たような形の型が、3か所くらいに別々に存在してたよ。おんなじものを3回も書いたの?
こめだったら、2回目でもう飽きちゃうよ〜。
書き写す回数が増えるほど、どれかがズレちゃいそうだもんね〜。
実際に、手書きの型がズレていた
それがね、本当にズレてたんだ。
会話に添える
linkには、商品画像のURLを入れるimageという項目がスキーマ側にあるんだけど、あるコンポーネントの手書きの型にはこの項目が抜けてたんだよね。それって、記事の表示がおかしくなってたってこと…?
そこまではいかなかったよ。たまたま、その項目を使ってる記事がまだ無かっただけなんだ。
でも怖いのはそこで、スキーマと無関係に手書きした型は、ズレていてもTypeScriptが何も言ってくれない。これから使おうとしたタイミングで、静かにハマってた可能性はあったよね。
だれも教えてくれないの? こわい〜。
だから、手書きの型はやめて、さっきの
ConversationTurnみたいにスキーマから取り出す形に直したんだ。
型を手で書かなければ、そもそも書き間違えようがないからね。公開前にastro checkとビルドを通す
最後に、スキーマを活かすための習慣を1つ。
記事を書いたら、まず
astro checkで型とスキーマの問題を確認し、そのあと本番ビルドまで通す。Content Collectionsのチェックはビルド時に走るので、ここを省くと「書いたつもりで抜けている項目」に気づけない。
pnpm astro check
pnpm build書いた直後に確認する癖をつけておくと安心だね〜。
うん、最後の砦はここだと思ってるよ。
まとめると、「ローダーで集める」「スキーマで形を決める」「getCollectionで取り出す」の3つが基本。そこに「スキーマから型を取り出す」を足すと、記事が増えても壊れにくくなるんだ。同じ形をあちこちで手書きしてる心当たりがあったら、一度見直してみるといいかも!
こめの記事も、ちゃんとチェックしてね、パパ!
// 記事のまとめ
- まずは「ローダーで集める」「スキーマで形を決める」「getCollectionで取り出す」の3つを押さえれば十分
- 同じ形の型を複数箇所で手書きしている心当たりがあれば、CollectionEntryから取り出せないか見直す価値がある
- 手書きの型はスキーマとズレてもTypeScriptが教えてくれない。型を手で書かないのが一番安全
- 記事を書いたらastro checkとビルドまで通す。ここが入力ミスを見つける最後の砦になる


