このブログは、記事一覧ページも個別ページも、ルーティング用のコードを一切書いていません。src/content/blog/ に .mdx ファイルを1つ置けば、それだけで一覧に並び、/blog/<ファイル名>/ のURLでページが生成されます。Astro の Content Collections を、あえて薄く使うことを意識しました。

スキーマは4つのフィールドだけ

const blog = defineCollection({
  loader: glob({ pattern: "**/*.mdx", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    draft: z.boolean().default(false),
  }),
});

タグ、カテゴリ、アイキャッチ画像、著者情報……ブログのスキーマとして足したくなる項目はいくつも思いつきますが、あえて入れていません。個人の技術ブログで記事数もそう多くない前提なら、一覧のソートに使う date と、一覧・meta descriptionを兼ねる description があれば十分に運用できます。フィールドを足すほど「このMDXは何を埋めればいいんだっけ」と毎回フロントマターを見返すコストが増えるので、最小構成のまま数ヶ月運用してみて、本当に足りなくなってから拡張する方針にしました。

ファイル名 = URL、をそのまま採用する

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

post.id は Content Collections がファイル名から自動で採番するスラッグで、これをそのまま params.id に渡しています。つまり pixel-art-string-grid-engine.mdx を置けば /blog/pixel-art-string-grid-engine/ が生成されます。スラッグを別フィールドとして持たせる設計も検討しましたが、「ファイル名を見ればURLが分かる」方が、記事を書く側にとっての認知負荷が低いと判断しました。

draft: true は「除外条件」を1箇所に集約する

下書きの仕組みは、一覧取得と個別ページ生成の両方で同じフィルタ関数を使うことで、抜け漏れを防いでいます。

const posts = await getCollection("blog", ({ data }) => !data.draft);

このフィルタを一覧ページ(index.astro)と個別ページ生成(getStaticPaths)の両方に書いているのですが、もし片方だけに draft の判定を書き忘れると、「一覧には出ないのにURLを直接叩けば見えてしまう」という中途半端な非公開状態が生まれます。今のところは2箇所への重複記述で運用していますが、将来的にはフィルタ済みの取得関数を1つのユーティリティに切り出すことも検討しています。

Markdownではなく MDX を選んだ理由

現状の記事はすべて素のMarkdown相当の内容ですが、拡張子はあえて .mdx にしています。技術記事では「このセクションだけ折りたたみたい」「Beforeと Afterを並べて見せたい」といった、コンポーネントを埋め込みたくなる場面が今後出てくることが予想できたためです。最初から .md で始めて後から .mdx に一括リネームするより、最初から .mdx にしておいて「使わなければただのMarkdown」という状態にしておく方が、移行コストがゼロになります。

getCollection の呼び出しはビルド時にしか走らない

Astro は静的サイト生成(SSG)なので、getCollection や getStaticPaths の実行はビルド時の1回のみで、閲覧者のリクエストごとにファイルシステムを読みには行きません。記事を増やしても実行時のパフォーマンスに影響しない、という前提があるからこそ、フィルタ処理をシンプルに保てています。

「まず最小のスキーマで組んで、困ってから足す」というやり方は、フロントマターの設計に限らず、個人プロジェクトの初期設計全般に効く考え方だと感じています。