Astro Content Collections 活用ガイド - 型安全なコンテンツ管理を実現


Astro Content Collectionsは、Markdown/MDXファイルを型安全に管理できる強力な機能です。本記事では、基本的な使い方から実践的なテクニックまで、包括的に解説します。

Content Collectionsとは

Content Collectionsは、Astro 2.0で導入された、コンテンツファイル(Markdown/MDX)を構造化し、型安全に扱うための機能です。Zodスキーマを使ってfrontmatterの型を定義し、TypeScriptによる完全な型チェックを実現します。

主な特徴

  • 型安全性: TypeScript + Zodによる厳密な型チェック
  • パフォーマンス: ビルド時の最適化とキャッシング
  • 開発者体験: 自動補完とエラー検出
  • 柔軟性: カスタムスキーマ、リレーション、変換処理

基本的なセットアップ

ディレクトリ構造

src/
├── content/
│   ├── config.ts          # スキーマ定義
│   ├── blog/              # ブログ記事コレクション
│   │   ├── post-1.md
│   │   └── post-2.mdx
│   └── authors/           # 著者コレクション
│       ├── alice.md
│       └── bob.md
└── pages/
    └── blog/
        └── [slug].astro

スキーマの定義

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    tags: z.array(z.string()),
    draft: z.boolean().default(false),
    featured: z.boolean().default(false),
    author: z.string(),
    coverImage: z.string().optional(),
  }),
});

const authorCollection = defineCollection({
  type: 'content',
  schema: z.object({
    name: z.string(),
    bio: z.string(),
    avatar: z.string(),
    twitter: z.string().optional(),
    github: z.string().optional(),
  }),
});

export const collections = {
  blog: blogCollection,
  authors: authorCollection,
};

コンテンツファイルの作成

---
title: "Astro Content Collections入門"
description: "型安全なコンテンツ管理の始め方"
pubDate: 2025-02-05
tags: ["astro", "typescript"]
author: "alice"
featured: true
---

本文がここに入ります。Markdown記法が使えます。

## セクション1

コンテンツ...

コンテンツのクエリ

すべてのエントリを取得

// src/pages/blog/index.astro
---
import { getCollection } from 'astro:content';

// すべてのブログ記事を取得
const allPosts = await getCollection('blog');

// 公開済み記事のみ取得(下書きを除外)
const publishedPosts = await getCollection('blog', ({ data }) => {
  return data.draft !== true;
});

// タグでフィルタリング
const astroPosts = await getCollection('blog', ({ data }) => {
  return data.tags.includes('astro');
});
---

<ul>
  {publishedPosts.map((post) => (
    <li>
      <a href={`/blog/${post.slug}`}>
        {post.data.title}
      </a>
    </li>
  ))}
</ul>

単一エントリの取得

// src/pages/blog/[slug].astro
---
import { getEntry } from 'astro:content';

const { slug } = Astro.params;
const post = await getEntry('blog', slug);

if (!post) {
  return Astro.redirect('/404');
}

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

<article>
  <h1>{post.data.title}</h1>
  <p>{post.data.description}</p>
  <time datetime={post.data.pubDate.toISOString()}>
    {post.data.pubDate.toLocaleDateString('ja-JP')}
  </time>

  <Content />
</article>

動的ルーティング

// src/pages/blog/[slug].astro
---
import { getCollection } from 'astro:content';

// 静的サイト生成のためのパスを生成
export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => {
    return data.draft !== true;
  });

  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

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

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

  <!-- 目次の生成 -->
  <nav>
    <h2>目次</h2>
    <ul>
      {headings.map((heading) => (
        <li style={`margin-left: ${(heading.depth - 1) * 20}px`}>
          <a href={`#${heading.slug}`}>{heading.text}</a>
        </li>
      ))}
    </ul>
  </nav>

  <Content />
</article>

ソートとフィルタリング

日付でソート

// 最新の記事順
const sortedPosts = allPosts.sort((a, b) => {
  return b.data.pubDate.getTime() - a.data.pubDate.getTime();
});

// 古い記事順
const oldestFirst = allPosts.sort((a, b) => {
  return a.data.pubDate.getTime() - b.data.pubDate.getTime();
});

複雑なフィルタリング

// 注目記事かつ特定タグを含む
const featuredAstroPosts = await getCollection('blog', ({ data }) => {
  return data.featured && data.tags.includes('astro') && !data.draft;
});

// 特定期間の記事
const recentPosts = await getCollection('blog', ({ data }) => {
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
  return data.pubDate >= thirtyDaysAgo;
});

// 特定著者の記事
const alicePosts = await getCollection('blog', ({ data }) => {
  return data.author === 'alice';
});

ページネーション

// src/pages/blog/page/[page].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths({ paginate }) {
  const allPosts = await getCollection('blog', ({ data }) => {
    return !data.draft;
  });

  const sortedPosts = allPosts.sort((a, b) => {
    return b.data.pubDate.getTime() - a.data.pubDate.getTime();
  });

  // 1ページあたり10記事
  return paginate(sortedPosts, { pageSize: 10 });
}

const { page } = Astro.props;
---

<div>
  {page.data.map((post) => (
    <article>
      <h2>{post.data.title}</h2>
      <p>{post.data.description}</p>
      <a href={`/blog/${post.slug}`}>続きを読む</a>
    </article>
  ))}
</div>

<!-- ページネーションナビゲーション -->
<nav>
  {page.url.prev && <a href={page.url.prev}>前へ</a>}
  <span>ページ {page.currentPage} / {page.lastPage}</span>
  {page.url.next && <a href={page.url.next}>次へ</a>}
</nav>

高度なスキーマ定義

カスタムバリデーション

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string().min(10).max(100),
    description: z.string().min(50).max(200),

    // URLの検証
    canonicalUrl: z.string().url().optional(),

    // メールアドレスの検証
    authorEmail: z.string().email(),

    // 列挙型
    category: z.enum(['tech', 'design', 'business']),

    // タグは最低1つ、最大5つ
    tags: z.array(z.string()).min(1).max(5),

    // 読了時間(分)は正の整数
    readingTime: z.number().int().positive(),

    // 公開日は過去の日付のみ
    pubDate: z.date().max(new Date()),

    // カスタムバリデーション
    slug: z.string().regex(/^[a-z0-9-]+$/),
  }),
});

デフォルト値と変換

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),

    // デフォルト値
    draft: z.boolean().default(false),
    featured: z.boolean().default(false),
    views: z.number().default(0),

    // 日付の変換(文字列から日付へ)
    pubDate: z
      .string()
      .or(z.date())
      .transform((val) => new Date(val)),

    // タグの正規化(小文字に変換)
    tags: z
      .array(z.string())
      .transform((tags) => tags.map((tag) => tag.toLowerCase())),

    // オプショナルな値の処理
    coverImage: z.string().optional(),
    updatedDate: z.date().optional(),
  }),
});

リレーションの定義

import { defineCollection, reference, z } from 'astro:content';

const authorCollection = defineCollection({
  type: 'content',
  schema: z.object({
    name: z.string(),
    bio: z.string(),
    avatar: z.string(),
  }),
});

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),

    // 著者へのリファレンス
    author: reference('authors'),

    // 関連記事へのリファレンス(複数)
    relatedPosts: z.array(reference('blog')).optional(),
  }),
});

export const collections = {
  blog: blogCollection,
  authors: authorCollection,
};

リレーションの使用

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

const post = await getEntry('blog', Astro.params.slug);

// 著者情報を取得
const author = await getEntry(post.data.author);

// 関連記事を取得
const relatedPosts = post.data.relatedPosts
  ? await Promise.all(
      post.data.relatedPosts.map((ref) => getEntry(ref))
    )
  : [];
---

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

  <!-- 著者情報 -->
  <div class="author">
    <img src={author.data.avatar} alt={author.data.name} />
    <div>
      <h3>{author.data.name}</h3>
      <p>{author.data.bio}</p>
    </div>
  </div>

  <!-- 記事本文 -->
  <Content />

  <!-- 関連記事 -->
  {relatedPosts.length > 0 && (
    <aside>
      <h2>関連記事</h2>
      <ul>
        {relatedPosts.map((relatedPost) => (
          <li>
            <a href={`/blog/${relatedPost.slug}`}>
              {relatedPost.data.title}
            </a>
          </li>
        ))}
      </ul>
    </aside>
  )}
</article>

MDXとの連携

MDXコンポーネントの使用

---
title: "インタラクティブな記事"
description: "MDXで作る動的なコンテンツ"
pubDate: 2025-02-05
tags: ["astro", "mdx"]
---

import { Code } from 'astro:components';
import Counter from '../../components/Counter.jsx';

# {frontmatter.title}

通常のMarkdownに加えて、コンポーネントが使えます。

<Counter client:load />

<Code code={`console.log('Hello, World!');`} lang="javascript" />

カスタムコンポーネントの設定

// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [mdx()],
  markdown: {
    shikiConfig: {
      theme: 'dracula',
      wrap: true,
    },
  },
});
// src/pages/blog/[slug].astro
---
import { getEntry } from 'astro:content';
import CodeBlock from '../../components/CodeBlock.astro';
import Callout from '../../components/Callout.astro';

const post = await getEntry('blog', Astro.params.slug);
const { Content } = await post.render();
---

<article>
  <Content components={{ pre: CodeBlock, blockquote: Callout }} />
</article>

タグページの自動生成

// src/pages/tags/[tag].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const allPosts = await getCollection('blog', ({ data }) => !data.draft);

  // すべてのタグを収集
  const allTags = new Set();
  allPosts.forEach((post) => {
    post.data.tags.forEach((tag) => allTags.add(tag));
  });

  // タグごとのページを生成
  return Array.from(allTags).map((tag) => {
    const tagPosts = allPosts.filter((post) =>
      post.data.tags.includes(tag)
    );

    return {
      params: { tag },
      props: { posts: tagPosts, tag },
    };
  });
}

const { posts, tag } = Astro.props;
---

<div>
  <h1>タグ: {tag}</h1>
  <p>{posts.length}件の記事</p>

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

RSSフィードの生成

// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';

export async function GET(context) {
  const posts = await getCollection('blog', ({ data }) => !data.draft);

  const sortedPosts = posts.sort((a, b) => {
    return b.data.pubDate.getTime() - a.data.pubDate.getTime();
  });

  return rss({
    title: 'My Blog',
    description: 'A blog about web development',
    site: context.site,
    items: sortedPosts.map((post) => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.pubDate,
      link: `/blog/${post.slug}/`,
      categories: post.data.tags,
      author: post.data.author,
    })),
    customData: '<language>ja</language>',
  });
}

検索機能の実装

シンプルな全文検索

// src/components/Search.astro
---
import { getCollection } from 'astro:content';

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

// クライアント側で検索するためのデータを用意
const searchData = allPosts.map((post) => ({
  slug: post.slug,
  title: post.data.title,
  description: post.data.description,
  tags: post.data.tags,
}));
---

<div id="search-container">
  <input
    type="search"
    id="search-input"
    placeholder="記事を検索..."
  />
  <div id="search-results"></div>
</div>

<script define:vars={{ searchData }}>
  const input = document.getElementById('search-input');
  const results = document.getElementById('search-results');

  input.addEventListener('input', (e) => {
    const query = e.target.value.toLowerCase();

    if (!query) {
      results.innerHTML = '';
      return;
    }

    const filtered = searchData.filter((post) => {
      return (
        post.title.toLowerCase().includes(query) ||
        post.description.toLowerCase().includes(query) ||
        post.tags.some((tag) => tag.toLowerCase().includes(query))
      );
    });

    results.innerHTML = filtered
      .map((post) => {
        return `
          <a href="/blog/${post.slug}">
            <h3>${post.title}</h3>
            <p>${post.description}</p>
          </a>
        `;
      })
      .join('');
  });
</script>

パフォーマンス最適化

画像の最適化

// src/pages/blog/[slug].astro
---
import { Image } from 'astro:assets';
import { getEntry } from 'astro:content';

const post = await getEntry('blog', Astro.params.slug);

// 画像のインポート
const images = import.meta.glob('/src/assets/blog/*.{png,jpg,jpeg}');
const coverImage = post.data.coverImage
  ? await images[`/src/assets/blog/${post.data.coverImage}`]()
  : null;
---

<article>
  {coverImage && (
    <Image
      src={coverImage.default}
      alt={post.data.title}
      width={1200}
      height={630}
      format="webp"
      quality={80}
    />
  )}

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

コンテンツのキャッシング

// src/lib/content-cache.ts
import { getCollection } from 'astro:content';

let cachedPosts: Awaited<ReturnType<typeof getCollection>> | null = null;

export async function getCachedPosts() {
  if (!cachedPosts) {
    cachedPosts = await getCollection('blog', ({ data }) => !data.draft);
  }
  return cachedPosts;
}

// ビルド時のみキャッシュを使用
export async function getPosts() {
  if (import.meta.env.PROD) {
    return getCachedPosts();
  }
  return getCollection('blog', ({ data }) => !data.draft);
}

ベストプラクティス

1. 明確なスキーマ定義

// すべての必須フィールドを明確に定義
const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    // 必須フィールド
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    author: reference('authors'),
    tags: z.array(z.string()).min(1),

    // オプションフィールド
    updatedDate: z.date().optional(),
    coverImage: z.string().optional(),
    canonicalUrl: z.string().url().optional(),

    // デフォルト値付きフィールド
    draft: z.boolean().default(false),
    featured: z.boolean().default(false),
  }),
});

2. エラーハンドリング

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

const { slug } = Astro.params;

let post;
try {
  post = await getEntry('blog', slug);
} catch (error) {
  console.error('Failed to fetch post:', error);
  return Astro.redirect('/404');
}

if (!post) {
  return Astro.redirect('/404');
}

if (post.data.draft && import.meta.env.PROD) {
  return Astro.redirect('/404');
}
---

3. 型安全なユーティリティ関数

// src/lib/content-utils.ts
import type { CollectionEntry } from 'astro:content';

export function sortByDate(
  posts: CollectionEntry<'blog'>[],
  order: 'asc' | 'desc' = 'desc'
) {
  return posts.sort((a, b) => {
    const diff = b.data.pubDate.getTime() - a.data.pubDate.getTime();
    return order === 'desc' ? diff : -diff;
  });
}

export function filterByTag(
  posts: CollectionEntry<'blog'>[],
  tag: string
) {
  return posts.filter((post) => post.data.tags.includes(tag));
}

export function groupByYear(posts: CollectionEntry<'blog'>[]) {
  return posts.reduce((acc, post) => {
    const year = post.data.pubDate.getFullYear();
    if (!acc[year]) acc[year] = [];
    acc[year].push(post);
    return acc;
  }, {} as Record<number, CollectionEntry<'blog'>[]>);
}

まとめ

Astro Content Collectionsは、型安全で効率的なコンテンツ管理を実現する強力な機能です。Zodスキーマによる厳密な型定義、柔軟なクエリAPI、MDXとのシームレスな連携により、大規模なコンテンツサイトでも安心して開発できます。

本記事で紹介したテクニックを活用することで、保守性が高く、パフォーマンスに優れたコンテンツサイトを構築できます。まずは基本的なセットアップから始めて、徐々に高度な機能を取り入れていきましょう。