Astro 5のContent Layer API完全解説 — 型安全なコンテンツ管理
Astro 5で導入されたContent Layer APIは、コンテンツ管理の方法を根本から変えました。従来のファイルベースのコンテンツコレクションに加え、外部API、データベース、HeadlessCMSなど、あらゆるデータソースを統一的に扱えるようになりました。この記事では、Content Layer APIの仕組みと実践的な使い方を解説します。
Content Layer APIとは
Content Layer APIは、Astroのコンテンツソースを抽象化するレイヤーです。主な特徴は以下の通りです。
- 統一されたインターフェース - ローカルファイルもAPIも同じように扱える
- 型安全 - Zodスキーマから自動的に型が生成される
- キャッシュ機構 - ビルド時のパフォーマンスを最適化
- Server Islands対応 - 動的コンテンツの配信
基本的な使い方
1. コンテンツコレクションの定義
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content', // or 'data'
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
2. コンテンツの取得
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog', ({ data }) => {
return data.draft !== true;
});
const sortedPosts = allPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<ul>
{sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>
{post.data.title}
</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('ja-JP')}
</time>
</li>
))}
</ul>
3. 個別記事の表示
---
// src/pages/blog/[...slug].astro
import { getCollection, getEntry } from 'astro:content';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}) satisfies GetStaticPaths;
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString('ja-JP')}</time>
<Content />
</article>
カスタムローダーの作成
Content Layer APIの真価は、カスタムローダーで外部データソースを統合できる点にあります。
NotionをCMSとして使う
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_TOKEN });
const notionLoader = {
name: 'notion-loader',
async load() {
const databaseId = process.env.NOTION_DATABASE_ID!;
const response = await notion.databases.query({
database_id: databaseId,
filter: {
property: 'Published',
checkbox: {
equals: true,
},
},
});
return response.results.map((page: any) => {
const properties = page.properties;
return {
id: page.id,
slug: properties.Slug.rich_text[0]?.plain_text || page.id,
data: {
title: properties.Title.title[0]?.plain_text || 'Untitled',
description: properties.Description.rich_text[0]?.plain_text || '',
pubDate: new Date(properties.Date.date?.start || page.created_time),
tags: properties.Tags.multi_select.map((tag: any) => tag.name),
},
};
});
},
};
const blog = defineCollection({
loader: notionLoader,
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()),
}),
});
export const collections = { blog };
GitHub Issuesをコンテンツソースにする
// src/content/loaders/github-issues.ts
import { Octokit } from '@octokit/rest';
export function githubIssuesLoader(owner: string, repo: string) {
return {
name: 'github-issues-loader',
async load() {
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const { data: issues } = await octokit.issues.listForRepo({
owner,
repo,
state: 'open',
labels: 'blog-post',
});
return issues.map((issue) => ({
id: String(issue.number),
slug: issue.number.toString(),
data: {
title: issue.title,
body: issue.body || '',
pubDate: new Date(issue.created_at),
updatedDate: new Date(issue.updated_at),
author: issue.user?.login || 'unknown',
labels: issue.labels.map((label) =>
typeof label === 'string' ? label : label.name || ''
),
},
}));
},
};
}
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { githubIssuesLoader } from './loaders/github-issues';
const issues = defineCollection({
loader: githubIssuesLoader('your-username', 'your-repo'),
schema: z.object({
title: z.string(),
body: z.string(),
pubDate: z.date(),
updatedDate: z.date(),
author: z.string(),
labels: z.array(z.string()),
}),
});
export const collections = { issues };
外部データソース統合の実例
1. microCMSとの統合
// src/content/loaders/microcms.ts
import { createClient } from 'microcms-js-sdk';
const client = createClient({
serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN!,
apiKey: process.env.MICROCMS_API_KEY!,
});
export function microCMSLoader(endpoint: string) {
return {
name: 'microcms-loader',
async load() {
const { contents } = await client.getList({
endpoint,
queries: {
limit: 100,
},
});
return contents.map((content: any) => ({
id: content.id,
slug: content.slug || content.id,
data: {
title: content.title,
description: content.description,
body: content.body,
pubDate: new Date(content.publishedAt),
updatedDate: new Date(content.updatedAt),
category: content.category?.name,
tags: content.tags?.map((tag: any) => tag.name) || [],
},
}));
},
};
}
// src/content/config.ts
const blog = defineCollection({
loader: microCMSLoader('blog'),
schema: z.object({
title: z.string(),
description: z.string(),
body: z.string(),
pubDate: z.date(),
updatedDate: z.date(),
category: z.string().optional(),
tags: z.array(z.string()),
}),
});
2. Supabaseとの統合
// src/content/loaders/supabase.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
export function supabaseLoader(table: string) {
return {
name: 'supabase-loader',
async load() {
const { data, error } = await supabase
.from(table)
.select('*')
.eq('published', true)
.order('created_at', { ascending: false });
if (error) throw error;
return data.map((row) => ({
id: row.id,
slug: row.slug,
data: {
title: row.title,
description: row.description,
content: row.content,
pubDate: new Date(row.created_at),
author: row.author_id,
tags: row.tags,
},
}));
},
};
}
型推論の活用
Content Layer APIはZodスキーマから自動的に型を生成します。
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
tags: z.array(z.string()),
author: z.object({
name: z.string(),
url: z.string().url(),
}).optional(),
}),
});
export const collections = { blog };
// 型の自動推論
// src/components/BlogCard.astro
---
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
// post.data.title は string型
// post.data.author は { name: string; url: string } | undefined 型
---
<article>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
{post.data.author && (
<a href={post.data.author.url}>{post.data.author.name}</a>
)}
</article>
Server Islandsとの組み合わせ
Astro 5のServer Islandsを使えば、動的なコンテンツもSSGサイトに組み込めます。
---
// src/components/RecentPosts.astro
import { getCollection } from 'astro:content';
// このコンポーネントはリクエスト時に実行される
const recentPosts = await getCollection('blog', ({ data }) => {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return data.pubDate >= oneWeekAgo;
});
---
<div server:defer>
<h2>最近の投稿</h2>
<ul>
{recentPosts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
</li>
))}
</ul>
</div>
キャッシュ戦略
Content Layer APIは自動的にキャッシュを管理しますが、カスタムローダーでキャッシュ戦略を制御できます。
export function cachedLoader(fetcher: () => Promise<any[]>) {
return {
name: 'cached-loader',
async load() {
// キャッシュの有効期限を設定
const cacheKey = 'my-content-cache';
const cacheDuration = 1000 * 60 * 60; // 1時間
// ... キャッシュロジック
const data = await fetcher();
return data;
},
// 開発時にはキャッシュをスキップ
watch: process.env.NODE_ENV === 'development',
};
}
まとめ
Astro 5のContent Layer APIは、静的サイトジェネレーターの可能性を大きく広げました。
主なメリット:
- 型安全なコンテンツ管理
- 柔軟なデータソース統合
- パフォーマンスの最適化
- Server Islandsとの連携
HeadlessCMS、データベース、外部APIなど、あらゆるコンテンツソースをAstroで統一的に扱えるようになったことで、より柔軟なサイト構築が可能になりました。カスタムローダーを活用して、プロジェクトに最適なコンテンツ管理を実現しましょう。