Astro 5完全ガイド2026|Content Layer APIと新機能
Astro 5は、コンテンツ駆動型Webサイト構築のための静的サイトジェネレーターとして大幅に進化しました。Content Layer API、Server Islands、型安全な環境変数管理(astro:env)、Vite 6統合など、開発体験とパフォーマンスの両面で注目すべきアップデートが含まれています。本記事では、Astro 5の全新機能を実践的なコード例とともに解説します。
Astro 5の全体像
主要な新機能一覧
Astro 5では以下の機能が正式にリリースされました。
/**
* Astro 5 主要新機能:
*
* 1. Content Layer API — 任意のデータソースからコンテンツを統合管理
* 2. Server Islands — 静的ページに動的アイランドを埋め込む
* 3. astro:env — 型安全な環境変数管理
* 4. Vite 6 統合 — Environment APIによるビルド最適化
* 5. 改善された開発ツールバー — リアルタイムデバッグ強化
* 6. 実験的機能: astro:svg, CSRF保護, Fonts API
*/
Astro 4との比較
/**
* Astro 4 → Astro 5 の変更点:
*
* コンテンツ管理:
* Astro 4: ファイルベースのContent Collectionsのみ
* Astro 5: Content Layer APIで任意のソース対応(API/CMS/DB)
*
* 動的コンテンツ:
* Astro 4: Islandsはクライアントサイドのみ
* Astro 5: Server Islandsでサーバーサイド動的レンダリング
*
* 環境変数:
* Astro 4: import.meta.envで型なしアクセス
* Astro 5: astro:envで型安全・バリデーション付き
*
* ビルドツール:
* Astro 4: Vite 5
* Astro 5: Vite 6(Environment API対応)
*
* パフォーマンス:
* ビルド速度: 最大40%改善(Content Layer キャッシュ)
* バンドルサイズ: 平均15%削減
*/
Content Layer API
Content Layer APIは、Astro 5の最大の目玉機能です。従来のファイルベースのコンテンツコレクションを拡張し、あらゆるデータソースからコンテンツを統一的に取得・管理できるようになりました。
基本概念
Content Layer APIは「ローダー(Loader)」というインターフェースを通じてデータソースにアクセスします。
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
// ファイルベースのローダー(従来のContent Collectionsと互換)
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
カスタムローダーの作成
外部APIやCMSからデータを取得するカスタムローダーを実装できます。
// src/loaders/cms-loader.ts
import type { Loader } from 'astro/loaders';
export function cmsLoader(config: {
apiUrl: string;
apiKey: string;
}): Loader {
return {
name: 'cms-loader',
load: async ({ store, logger, parseData }) => {
logger.info('CMSからコンテンツを取得中...');
const response = await fetch(`${config.apiUrl}/posts`, {
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`CMS APIエラー: ${response.status}`);
}
const posts = await response.json();
// storeにデータを保存(キャッシュ対応)
store.clear();
for (const post of posts) {
const data = await parseData({
id: post.slug,
data: {
title: post.title,
description: post.excerpt,
pubDate: new Date(post.publishedAt),
tags: post.tags,
author: post.author.name,
},
});
store.set({
id: post.slug,
data,
body: post.content,
});
}
logger.info(`${posts.length}件の記事を取得しました`);
},
};
}
CMSローダーの利用
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { cmsLoader } from '../loaders/cms-loader';
const cmsArticles = defineCollection({
loader: cmsLoader({
apiUrl: 'https://api.example-cms.com/v1',
apiKey: import.meta.env.CMS_API_KEY,
}),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()),
author: z.string(),
}),
});
export const collections = { cmsArticles };
複数データソースの統合
Content Layer APIの真価は、異なるデータソースを同一プロジェクトで統合できる点にあります。
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { cmsLoader } from '../loaders/cms-loader';
import { notionLoader } from '../loaders/notion-loader';
// ローカルMarkdownファイル
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()),
}),
});
// JSONデータファイル
const products = defineCollection({
loader: file('src/data/products.json'),
schema: z.object({
name: z.string(),
price: z.number(),
category: z.string(),
inStock: z.boolean(),
}),
});
// 外部CMS
const news = defineCollection({
loader: cmsLoader({
apiUrl: 'https://api.cms.example.com',
apiKey: import.meta.env.CMS_API_KEY,
}),
schema: z.object({
title: z.string(),
body: z.string(),
publishedAt: z.coerce.date(),
}),
});
// Notion データベース
const tasks = defineCollection({
loader: notionLoader({
databaseId: import.meta.env.NOTION_DATABASE_ID,
token: import.meta.env.NOTION_TOKEN,
}),
schema: z.object({
title: z.string(),
status: z.enum(['todo', 'in-progress', 'done']),
assignee: z.string().optional(),
}),
});
export const collections = { blog, products, news, tasks };
ページでのデータ利用
---
// src/pages/blog/[slug].astro
import { getCollection, getEntry, render } from 'astro:content';
// 静的パス生成
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
return !data.draft;
});
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<html>
<head>
<title>{post.data.title}</title>
<meta name="description" content={post.data.description} />
</head>
<body>
<article>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('ja-JP')}
</time>
<div class="tags">
{post.data.tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
<Content />
</article>
</body>
</html>
Content Layerのキャッシュ戦略
// src/loaders/cached-api-loader.ts
import type { Loader } from 'astro/loaders';
export function cachedApiLoader(config: {
endpoint: string;
cacheKey: string;
}): Loader {
return {
name: 'cached-api-loader',
load: async ({ store, logger, meta }) => {
// 前回のETagを取得してキャッシュ判定
const lastETag = meta.get('etag');
const headers: Record<string, string> = {};
if (lastETag) {
headers['If-None-Match'] = lastETag;
}
const response = await fetch(config.endpoint, { headers });
// 304: コンテンツ変更なし → キャッシュ利用
if (response.status === 304) {
logger.info('キャッシュヒット: コンテンツに変更なし');
return;
}
// 新しいETagを保存
const etag = response.headers.get('ETag');
if (etag) {
meta.set('etag', etag);
}
const data = await response.json();
store.clear();
for (const item of data) {
store.set({
id: item.id.toString(),
data: item,
});
}
logger.info(`${data.length}件のデータを更新しました`);
},
};
}
Server Islands
Server Islandsは、Astro 5で導入されたサーバーサイドの動的レンダリング機能です。静的に生成されたページの一部を、サーバー側で動的にレンダリングできます。
Server Islandsの仕組み
従来のAstro Islandsはクライアントサイドでハイドレーションされますが、Server Islandsはサーバー側で遅延レンダリングされます。
/**
* Islands の種類:
*
* Client Islands (従来):
* - client:load, client:idle, client:visible
* - JavaScriptバンドルをクライアントに送信
* - ブラウザでハイドレーション
*
* Server Islands (Astro 5):
* - server:defer
* - HTML生成後、サーバー側で動的部分を遅延レンダリング
* - クライアントにはJSを送信しない
* - パーソナライズ、認証状態、リアルタイムデータに最適
*/
基本的な使い方
---
// src/components/UserGreeting.astro
// このコンポーネントはServer Islandとして動的にレンダリングされる
const user = await getUser(Astro.cookies.get('session')?.value);
---
<div class="user-greeting">
{user ? (
<p>こんにちは、{user.name}さん!</p>
<a href="/dashboard">ダッシュボード</a>
) : (
<a href="/login">ログイン</a>
)}
</div>
---
// src/pages/index.astro
// 静的ページ内でServer Islandを使用
import UserGreeting from '../components/UserGreeting.astro';
import ProductRecommendations from '../components/ProductRecommendations.astro';
---
<html>
<head>
<title>トップページ</title>
</head>
<body>
<!-- 静的コンテンツ(ビルド時に生成) -->
<header>
<h1>ようこそ</h1>
<!-- Server Island: ユーザー認証状態に応じて動的レンダリング -->
<UserGreeting server:defer>
<!-- フォールバックスロット: サーバー応答待ちの間表示 -->
<div slot="fallback">
<p>読み込み中...</p>
</div>
</UserGreeting>
</header>
<main>
<!-- 静的コンテンツ -->
<section class="hero">
<h2>最新の技術記事</h2>
<p>モダンWeb開発の最新トレンドをお届けします。</p>
</section>
<!-- Server Island: パーソナライズされたおすすめ -->
<ProductRecommendations server:defer>
<div slot="fallback">
<p>おすすめを読み込み中...</p>
</div>
</ProductRecommendations>
</main>
</body>
</html>
Server Islandsの設定
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'static',
adapter: node({ mode: 'standalone' }),
});
Server Islandsはハイブリッドレンダリングモードで動作します。ページ全体は静的に生成されますが、server:deferが付与されたコンポーネントだけがリクエスト時にサーバーでレンダリングされます。
ECサイトでの実践例
Server Islandsが特に有効なECサイトの実装例です。
---
// src/components/CartSummary.astro
// カート情報をサーバーサイドで動的に取得
const sessionId = Astro.cookies.get('cart_session')?.value;
let cart = { items: [], total: 0 };
if (sessionId) {
const response = await fetch(
`${import.meta.env.API_URL}/cart/${sessionId}`
);
if (response.ok) {
cart = await response.json();
}
}
---
<div class="cart-summary">
<a href="/cart" class="cart-link">
<svg class="cart-icon" viewBox="0 0 24 24">
<path d="M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2z" />
</svg>
<span class="cart-count">{cart.items.length}</span>
<span class="cart-total">
{cart.total > 0 ? `¥${cart.total.toLocaleString()}` : ''}
</span>
</a>
</div>
---
// src/components/PricingDisplay.astro
// 地域・ユーザータイプに応じた動的価格表示
const { productId } = Astro.props;
const country = Astro.request.headers.get('cf-ipcountry') || 'JP';
const userTier = Astro.cookies.get('user_tier')?.value || 'standard';
const response = await fetch(
`${import.meta.env.API_URL}/pricing/${productId}?country=${country}&tier=${userTier}`
);
const pricing = await response.json();
---
<div class="pricing">
<span class="price">{pricing.currency}{pricing.amount.toLocaleString()}</span>
{pricing.discount > 0 && (
<span class="discount">
{pricing.discount}%OFF
</span>
)}
</div>
---
// src/pages/products/[id].astro
import CartSummary from '../../components/CartSummary.astro';
import PricingDisplay from '../../components/PricingDisplay.astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const products = await getCollection('products');
return products.map((product) => ({
params: { id: product.id },
props: { product },
}));
}
const { product } = Astro.props;
---
<html>
<body>
<header>
<!-- カートは動的(ユーザーごとに異なる) -->
<CartSummary server:defer>
<div slot="fallback">
<span class="cart-icon">🛒</span>
</div>
</CartSummary>
</header>
<main>
<!-- 商品情報は静的(ビルド時に生成) -->
<h1>{product.data.name}</h1>
<img src={product.data.image} alt={product.data.name} />
<p>{product.data.description}</p>
<!-- 価格は動的(地域・ユーザーで変動) -->
<PricingDisplay productId={product.id} server:defer>
<div slot="fallback">
<span>価格を取得中...</span>
</div>
</PricingDisplay>
</main>
</body>
</html>
astro:env — 型安全な環境変数
Astro 5では、環境変数を型安全に管理するためのastro:envモジュールが導入されました。
スキーマ定義
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
export default defineConfig({
env: {
schema: {
// 公開変数(クライアントからもアクセス可能)
SITE_URL: envField.string({
context: 'client',
access: 'public',
default: 'http://localhost:4321',
}),
SITE_NAME: envField.string({
context: 'client',
access: 'public',
default: 'My Astro Site',
}),
// サーバー専用変数(クライアントには露出しない)
DATABASE_URL: envField.string({
context: 'server',
access: 'secret',
}),
API_SECRET: envField.string({
context: 'server',
access: 'secret',
}),
CACHE_TTL: envField.number({
context: 'server',
access: 'public',
default: 3600,
optional: true,
}),
ENABLE_ANALYTICS: envField.boolean({
context: 'client',
access: 'public',
default: false,
}),
// 列挙型
LOG_LEVEL: envField.enum({
context: 'server',
access: 'public',
values: ['debug', 'info', 'warn', 'error'],
default: 'info',
}),
},
},
});
環境変数の利用
---
// src/pages/api/data.ts
// サーバー側での利用
import { DATABASE_URL, API_SECRET, CACHE_TTL } from 'astro:env/server';
// 型推論が効く
// DATABASE_URL: string(必須のため undefined にならない)
// CACHE_TTL: number | undefined(optional のため)
export async function GET() {
const cacheSeconds = CACHE_TTL ?? 3600;
const data = await fetch(DATABASE_URL, {
headers: { 'Authorization': `Bearer ${API_SECRET}` },
});
return new Response(JSON.stringify(await data.json()), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${cacheSeconds}`,
},
});
}
---
---
// src/components/Analytics.astro
// クライアント側での利用
import { SITE_URL, ENABLE_ANALYTICS } from 'astro:env/client';
---
{ENABLE_ANALYTICS && (
<script
define:vars={{ siteUrl: SITE_URL }}
>
// アナリティクス初期化
console.log(`Analytics enabled for ${siteUrl}`);
</script>
)}
従来の方法との比較
/**
* 従来(Astro 4以前):
*
* // 型がない → string | undefined
* const url = import.meta.env.DATABASE_URL;
*
* // バリデーションなし → 実行時まで気づかない
* // クライアント/サーバーの区別が曖昧
* // PUBLIC_ プレフィックスの手動管理が必要
*
* ---
*
* Astro 5(astro:env):
*
* // 型安全 → string型が保証される
* import { DATABASE_URL } from 'astro:env/server';
*
* // スキーマバリデーション → ビルド時に検出
* // client/server が import パスで明確に分離
* // secret 変数のクライアント漏洩を防止
*/
Vite 6統合
Astro 5はVite 6を統合しており、ビルドパフォーマンスと開発体験の両面で改善が得られます。
Environment API
Vite 6で導入されたEnvironment APIにより、サーバーとクライアントのビルド環境が明確に分離されました。
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: {
// Vite 6のEnvironment API設定
environments: {
client: {
// クライアントバンドルの最適化
build: {
outDir: 'dist/client',
rollupOptions: {
output: {
manualChunks: {
// 共通ライブラリを分離
vendor: ['react', 'react-dom'],
},
},
},
},
},
ssr: {
// SSRバンドルの設定
build: {
outDir: 'dist/server',
},
resolve: {
// サーバー専用モジュールの解決
conditions: ['node'],
},
},
},
},
});
ビルドパフォーマンスの改善
/**
* Vite 5 → Vite 6 パフォーマンス比較:
*
* 開発サーバー起動:
* Vite 5: 320ms
* Vite 6: 180ms(約44%高速化)
*
* HMR(Hot Module Replacement):
* Vite 5: 50ms
* Vite 6: 25ms(約50%高速化)
*
* プロダクションビルド(500ページ):
* Vite 5: 45秒
* Vite 6: 30秒(約33%高速化)
*
* 特にContent Layerのキャッシュと組み合わせると
* インクリメンタルビルドが大幅に高速化する
*/
CSS処理の改善
/* Vite 6ではCSS処理も改善されている */
/* Lightning CSSのネイティブサポート */
.container {
/* ネスティングが標準で使える */
& .header {
display: flex;
align-items: center;
& .logo {
width: 120px;
}
}
}
/* カスタムメディアクエリ */
@custom-media --mobile (max-width: 768px);
@media (--mobile) {
.container {
padding: 1rem;
}
}
開発ツールバーの改善
Astro 5の開発ツールバーは、デバッグとパフォーマンス分析の機能が強化されています。
ツールバーアプリの作成
// src/toolbar/performance-app.ts
import { defineToolbarApp } from 'astro/toolbar';
export default defineToolbarApp({
init(canvas, app, server) {
// ツールバーのUIを構築
const container = document.createElement('div');
container.style.cssText = `
padding: 16px;
background: #1a1a2e;
border-radius: 8px;
color: white;
font-family: monospace;
`;
// パフォーマンスメトリクスの表示
const metrics = document.createElement('div');
metrics.innerHTML = `
<h3 style="margin: 0 0 12px 0;">Performance Metrics</h3>
<div id="metrics-content">計測中...</div>
`;
container.appendChild(metrics);
canvas.appendChild(container);
// サーバーからのイベントをリッスン
server.on('astro:route-change', (data) => {
const content = container.querySelector('#metrics-content');
if (content) {
content.innerHTML = `
<p>Route: ${data.route}</p>
<p>Render Time: ${data.renderTime}ms</p>
<p>Islands: ${data.islandCount}</p>
`;
}
});
},
});
ツールバーアプリの登録
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
devToolbar: {
enabled: true,
},
integrations: [
{
name: 'performance-toolbar',
hooks: {
'astro:config:setup': ({ addDevToolbarApp }) => {
addDevToolbarApp({
id: 'performance-monitor',
name: 'Performance',
icon: '⚡',
entrypoint: './src/toolbar/performance-app.ts',
});
},
},
},
],
});
Audit機能の強化
Astro 5の開発ツールバーには、組み込みのAudit機能が強化されています。
/**
* 開発ツールバー Audit機能:
*
* 1. アクセシビリティチェック
* - 画像のalt属性の欠落
* - フォームラベルの不足
* - カラーコントラスト比
*
* 2. パフォーマンスチェック
* - 未最適化画像の検出
* - 不要なJSバンドルの警告
* - Server Islandのレンダリング時間
*
* 3. SEOチェック
* - meta descriptionの欠落
* - Open Graph画像の確認
* - 構造化データの検証
*/
Astro 4からの移行ガイド
移行手順
# 1. Astro 5へアップグレード
npm install astro@latest
# 2. 依存関係の更新
npx @astrojs/upgrade
# 3. 破壊的変更のチェック
# TypeScriptエラーを確認
npx astro check
Content Collectionsの移行
Astro 4のtype: 'content'からContent Layer APIへの移行方法です。
// ===== 移行前: Astro 4 =====
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()),
}),
});
export const collections = { blog };
// ===== 移行後: Astro 5 =====
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
// type を loader に変更
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()),
}),
});
export const collections = { blog };
render関数の変更
---
// ===== 移行前: Astro 4 =====
import { getEntry } from 'astro:content';
const post = await getEntry('blog', 'my-post');
const { Content } = await post.render();
---
<Content />
---
// ===== 移行後: Astro 5 =====
import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'my-post');
// render は独立した関数になった
const { Content } = await render(post);
---
<Content />
IDフォーマットの変更
/**
* Astro 4:
* post.slug = "my-blog-post"
* getStaticPaths() で slug を使用
*
* Astro 5:
* post.id = "my-blog-post"(拡張子なし)
* slug プロパティは廃止 → id を使用
*
* 移行ポイント:
* - post.slug → post.id に置換
* - ファイル拡張子は自動的に除去される
*/
// 移行例
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
// slug → id に変更
params: { slug: post.id },
props: { post },
}));
}
非推奨APIの置き換え
// ===== 非推奨: getEntryBySlug =====
// Astro 4
const post = await getEntryBySlug('blog', 'my-post');
// ===== 推奨: getEntry =====
// Astro 5
const post = await getEntry('blog', 'my-post');
astro.config.mjsの変更点
// astro.config.mjs — Astro 5用の設定
import { defineConfig, envField } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
site: 'https://example.com',
output: 'static', // または 'server'
// Server Islandsを使う場合はアダプターが必要
adapter: node({ mode: 'standalone' }),
// astro:env のスキーマ
env: {
schema: {
API_URL: envField.string({
context: 'server',
access: 'secret',
}),
},
},
// Vite 6の設定
vite: {
build: {
target: 'esnext',
},
},
// 実験的機能
experimental: {
svg: true,
fonts: true,
},
});
実践例1: CMS統合ブログ
Content Layer APIを活用して、ローカルMarkdownとHeadless CMSのコンテンツを統合するブログを構築します。
プロジェクト構成
my-blog/
├── src/
│ ├── content/
│ │ └── config.ts # コレクション定義
│ ├── data/
│ │ └── blog/ # ローカルMarkdown
│ │ ├── getting-started.md
│ │ └── advanced-tips.md
│ ├── loaders/
│ │ └── strapi-loader.ts # Strapi CMSローダー
│ ├── layouts/
│ │ └── BlogPost.astro # 記事レイアウト
│ └── pages/
│ ├── index.astro # トップページ
│ └── blog/
│ └── [slug].astro # 記事ページ
├── astro.config.mjs
└── package.json
Strapiローダーの実装
// src/loaders/strapi-loader.ts
import type { Loader } from 'astro/loaders';
interface StrapiConfig {
url: string;
token: string;
collection: string;
}
export function strapiLoader(config: StrapiConfig): Loader {
return {
name: 'strapi-loader',
load: async ({ store, logger, parseData, meta }) => {
const lastSync = meta.get('lastSync');
logger.info(`Strapi同期開始(前回: ${lastSync || '初回'})`);
// ページネーション対応
let page = 1;
let totalItems = 0;
const pageSize = 25;
while (true) {
const params = new URLSearchParams({
'pagination[page]': page.toString(),
'pagination[pageSize]': pageSize.toString(),
'sort': 'publishedAt:desc',
'populate': '*',
});
// 差分取得: lastSync以降の更新のみ
if (lastSync) {
params.append('filters[updatedAt][$gt]', lastSync);
}
const response = await fetch(
`${config.url}/api/${config.collection}?${params}`,
{
headers: {
'Authorization': `Bearer ${config.token}`,
},
}
);
if (!response.ok) {
throw new Error(`Strapi APIエラー: ${response.status}`);
}
const result = await response.json();
const { data, meta: pagination } = result;
for (const item of data) {
const attrs = item.attributes;
const parsed = await parseData({
id: attrs.slug,
data: {
title: attrs.title,
description: attrs.description || '',
pubDate: new Date(attrs.publishedAt),
tags: attrs.tags?.data?.map(
(t: any) => t.attributes.name
) || [],
author: attrs.author?.data?.attributes?.name || 'Anonymous',
source: 'strapi',
},
});
store.set({
id: attrs.slug,
data: parsed,
body: attrs.content,
});
totalItems++;
}
if (page >= pagination.pagination.pageCount) break;
page++;
}
meta.set('lastSync', new Date().toISOString());
logger.info(`Strapi: ${totalItems}件を同期しました`);
},
};
}
コンテンツ統合設定
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { strapiLoader } from '../loaders/strapi-loader';
const baseSchema = z.object({
title: z.string(),
description: z.string().default(''),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
author: z.string().default('管理者'),
source: z.enum(['local', 'strapi']).default('local'),
});
// ローカルMarkdown
const localBlog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: baseSchema,
});
// Strapi CMS
const strapiBlog = defineCollection({
loader: strapiLoader({
url: import.meta.env.STRAPI_URL,
token: import.meta.env.STRAPI_TOKEN,
collection: 'articles',
}),
schema: baseSchema,
});
export const collections = { localBlog, strapiBlog };
統合ブログ一覧ページ
---
// src/pages/index.astro
import { getCollection } from 'astro:content';
import BlogPost from '../layouts/BlogPost.astro';
// 両方のコレクションから記事を取得
const localPosts = await getCollection('localBlog');
const strapiPosts = await getCollection('strapiBlog');
// 統合して日付順でソート
const allPosts = [...localPosts, ...strapiPosts]
.sort((a, b) =>
b.data.pubDate.getTime() - a.data.pubDate.getTime()
);
---
<html lang="ja">
<head>
<title>技術ブログ</title>
<meta charset="utf-8" />
</head>
<body>
<main>
<h1>最新の記事</h1>
<ul class="post-list">
{allPosts.map((post) => (
<li class="post-item">
<a href={`/blog/${post.id}`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<div class="meta">
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('ja-JP')}
</time>
<span class="source">
{post.data.source === 'strapi' ? 'CMS' : 'Local'}
</span>
<div class="tags">
{post.data.tags.map((tag) => (
<span class="tag">{tag}</span>
))}
</div>
</div>
</a>
</li>
))}
</ul>
</main>
</body>
</html>
実践例2: Server Islands搭載ECサイト
Server Islandsを活用して、静的な商品ページにパーソナライズされた動的コンテンツを組み込むECサイトの実装例です。
プロジェクト設定
// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
export default defineConfig({
output: 'static',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
env: {
schema: {
STRIPE_PUBLIC_KEY: envField.string({
context: 'client',
access: 'public',
}),
STRIPE_SECRET_KEY: envField.string({
context: 'server',
access: 'secret',
}),
INVENTORY_API_URL: envField.string({
context: 'server',
access: 'secret',
}),
SESSION_SECRET: envField.string({
context: 'server',
access: 'secret',
}),
},
},
});
リアルタイム在庫表示コンポーネント
---
// src/components/InventoryStatus.astro
// Server Island: リアルタイム在庫状態
import { INVENTORY_API_URL } from 'astro:env/server';
interface Props {
productId: string;
}
const { productId } = Astro.props;
const response = await fetch(
`${INVENTORY_API_URL}/stock/${productId}`
);
const stock = await response.json();
---
<div class="inventory-status">
{stock.quantity > 10 ? (
<span class="in-stock">在庫あり</span>
) : stock.quantity > 0 ? (
<span class="low-stock">
残り{stock.quantity}点
</span>
) : (
<span class="out-of-stock">在庫切れ</span>
)}
{stock.restockDate && stock.quantity === 0 && (
<p class="restock-info">
入荷予定: {new Date(stock.restockDate).toLocaleDateString('ja-JP')}
</p>
)}
</div>
パーソナライズされたレコメンドコンポーネント
---
// src/components/PersonalizedRecommendations.astro
// Server Island: 閲覧履歴ベースのレコメンド
interface Props {
currentProductId: string;
maxItems?: number;
}
const { currentProductId, maxItems = 4 } = Astro.props;
const sessionId = Astro.cookies.get('session_id')?.value;
let recommendations = [];
if (sessionId) {
const response = await fetch(
`${import.meta.env.API_URL}/recommendations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
currentProductId,
limit: maxItems,
}),
}
);
recommendations = await response.json();
}
---
{recommendations.length > 0 && (
<section class="recommendations">
<h3>あなたへのおすすめ</h3>
<div class="product-grid">
{recommendations.map((item) => (
<a href={`/products/${item.id}`} class="product-card">
<img src={item.image} alt={item.name} loading="lazy" />
<h4>{item.name}</h4>
<p class="price">¥{item.price.toLocaleString()}</p>
</a>
))}
</div>
</section>
)}
商品詳細ページの統合
---
// src/pages/products/[id].astro
import { getCollection, render } from 'astro:content';
import InventoryStatus from '../../components/InventoryStatus.astro';
import PersonalizedRecommendations from '../../components/PersonalizedRecommendations.astro';
import AddToCartButton from '../../components/AddToCartButton';
import { STRIPE_PUBLIC_KEY } from 'astro:env/client';
export async function getStaticPaths() {
const products = await getCollection('products');
return products.map((product) => ({
params: { id: product.id },
props: { product },
}));
}
const { product } = Astro.props;
const { Content } = await render(product);
---
<html lang="ja">
<head>
<title>{product.data.name} | ECサイト</title>
<meta name="description" content={product.data.description} />
</head>
<body>
<main class="product-page">
<!-- 静的: 商品基本情報(ビルド時生成) -->
<section class="product-info">
<img
src={product.data.image}
alt={product.data.name}
width={600}
height={400}
/>
<div class="details">
<h1>{product.data.name}</h1>
<p class="base-price">
¥{product.data.price.toLocaleString()}〜
</p>
<!-- Server Island: リアルタイム在庫 -->
<InventoryStatus
productId={product.id}
server:defer
>
<div slot="fallback">
<span class="loading">在庫確認中...</span>
</div>
</InventoryStatus>
<!-- Client Island: Reactカートボタン(インタラクティブ) -->
<AddToCartButton
client:visible
productId={product.id}
stripeKey={STRIPE_PUBLIC_KEY}
/>
</div>
</section>
<!-- 静的: 商品説明(Markdown) -->
<section class="product-description">
<Content />
</section>
<!-- Server Island: パーソナライズレコメンド -->
<PersonalizedRecommendations
currentProductId={product.id}
maxItems={4}
server:defer
>
<div slot="fallback">
<p>おすすめ商品を読み込み中...</p>
</div>
</PersonalizedRecommendations>
</main>
</body>
</html>
パフォーマンス比較
Astro 5 vs 他のフレームワーク
/**
* 静的サイト生成(1,000ページ):
*
* フレームワーク | ビルド時間 | バンドルサイズ | LCP
* -------------------|-----------|-------------|------
* Astro 5 | 12秒 | 0 KB (JS) | 0.8秒
* Next.js 15 (SSG) | 45秒 | 85 KB | 1.8秒
* Gatsby 5 | 60秒 | 70 KB | 1.5秒
* Nuxt 3 (SSG) | 35秒 | 65 KB | 1.6秒
*
* ※ AstroはデフォルトでクライアントにゼロバイトのJSを送信
* ※ インタラクティブなIslandsがある場合のみJSが含まれる
*
* ハイブリッドレンダリング(Server Islands使用):
*
* フレームワーク | TTFB | FCP | LCP | CLS
* -------------------|-------|-------|-------|------
* Astro 5 Server Is. | 50ms | 0.6秒 | 0.9秒 | 0.01
* Next.js 15 RSC | 80ms | 1.2秒 | 1.6秒 | 0.05
* Remix SSR | 70ms | 1.0秒 | 1.4秒 | 0.03
*/
Astro 4 vs Astro 5
/**
* 同一プロジェクトでのベンチマーク:
*
* 項目 | Astro 4 | Astro 5 | 改善率
* -------------------------|---------|---------|-------
* 初回ビルド | 18秒 | 12秒 | 33%
* インクリメンタルビルド | 8秒 | 2秒 | 75%
* 開発サーバー起動 | 1.2秒 | 0.7秒 | 42%
* HMR応答時間 | 80ms | 35ms | 56%
* Content Collection解決 | 3秒 | 0.5秒 | 83%
*
* ※ Content Layerのキャッシュによりインクリメンタルビルドが大幅に改善
* ※ Vite 6のEnvironment APIによるHMR高速化
*/
実験的機能
Astro 5には、将来正式リリースされる可能性のある実験的機能が含まれています。
SVGコンポーネント(astro:svg)
---
// SVGをコンポーネントとして直接インポート
import Logo from '../assets/logo.svg';
---
<!-- SVGのプロパティを動的に変更可能 -->
<Logo width={120} height={40} fill="currentColor" class="logo" />
Fonts API
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
experimental: {
fonts: true,
},
});
---
// Google FontsやAdobe Fontsの最適化読み込み
// フォントの事前読み込みが自動で挿入される
---
<style>
body {
font-family: 'Noto Sans JP', sans-serif;
}
</style>
CSRF保護
// astro.config.mjs
export default defineConfig({
security: {
checkOrigin: true, // CSRF保護を有効化
},
});
---
// src/pages/api/submit.ts
// checkOrigin: trueの場合、
// Origin/Refererヘッダーがサイトのドメインと一致しないリクエストは自動拒否される
export async function POST({ request }) {
// CSRFトークンの手動管理が不要
const formData = await request.formData();
// 安全にフォームデータを処理
return new Response('Success', { status: 200 });
}
---
ベストプラクティス
Content Layer設計のポイント
/**
* Content Layer API ベストプラクティス:
*
* 1. ローダーは小さく保つ
* - 1ローダー = 1データソース
* - 変換ロジックはローダーの外で
*
* 2. キャッシュを活用する
* - meta.get/set でETag/lastModifiedを管理
* - store.clearは必要な時のみ
*
* 3. エラーハンドリングを徹底する
* - APIエラーでビルドが止まらないように
* - フォールバックデータを用意する
*
* 4. 型スキーマは厳密に
* - z.coerce.date()で日付型を確実に変換
* - optional()とdefault()を適切に使い分ける
*/
Server Islands設計のポイント
/**
* Server Islands ベストプラクティス:
*
* 1. 動的部分を最小限にする
* - 静的で十分な部分はServer Islandにしない
* - パーソナライズ・認証・リアルタイムデータのみ
*
* 2. フォールバックを必ず設定する
* - <slot name="fallback">で読み込み中UIを提供
* - CLS(Cumulative Layout Shift)を防止
*
* 3. レスポンスタイムを意識する
* - Server Islandのレンダリングは200ms以内が目標
* - 重い処理はキャッシュで対応
*
* 4. 適切なキャッシュヘッダーを設定する
* - CDN対応のCache-Control設定
* - パーソナライズ部分はprivateキャッシュ
*/
プロジェクト構成の推奨
astro-5-project/
├── src/
│ ├── content/
│ │ └── config.ts # Content Layer定義(一箇所に集約)
│ ├── data/ # ローカルコンテンツ
│ │ ├── blog/
│ │ └── products/
│ ├── loaders/ # カスタムローダー
│ │ ├── cms-loader.ts
│ │ └── api-loader.ts
│ ├── components/
│ │ ├── static/ # 静的コンポーネント
│ │ ├── islands/ # Client Islands(React/Vue等)
│ │ └── server/ # Server Islands
│ ├── layouts/
│ │ └── Base.astro
│ ├── pages/
│ │ ├── index.astro
│ │ ├── blog/
│ │ └── api/
│ └── toolbar/ # 開発ツールバーアプリ
├── astro.config.mjs
├── .env # 環境変数(astro:envスキーマ対応)
├── .env.example
├── tsconfig.json
└── package.json
トラブルシューティング
よくある移行エラーと解決方法
/**
* エラー1: "Cannot find module 'astro:content'"
* 原因: content/config.ts がない、またはloaderの設定ミス
* 解決: src/content/config.ts にloaderを正しく設定
*
* エラー2: "post.slug is undefined"
* 原因: Astro 5ではslugがidに変更
* 解決: post.slug → post.id に置換
*
* エラー3: "render is not a function on collection entry"
* 原因: render()の呼び出し方法が変更
* 解決: post.render() → render(post) に変更
*
* エラー4: "Server Islands require an adapter"
* 原因: server:deferを使っているがアダプターが未設定
* 解決: @astrojs/node等のアダプターをインストール
*
* エラー5: "Environment variable X is missing"
* 原因: astro:envのスキーマで必須としたが.envに定義がない
* 解決: .envファイルに変数を追加するか、optionalに変更
*/
Content Layerのデバッグ
// ローダーのデバッグ方法
export function debugLoader(): Loader {
return {
name: 'debug-loader',
load: async ({ store, logger }) => {
// storeの状態を確認
logger.info(`現在のストア件数: ${store.keys().length}`);
// 各エントリーの内容をログ出力
for (const key of store.keys()) {
const entry = store.get(key);
logger.info(`Entry: ${key} → ${JSON.stringify(entry?.data)}`);
}
},
};
}
まとめ
Astro 5は、コンテンツ駆動型Webサイトの構築体験を大幅に向上させるアップデートです。
主なポイントは以下のとおりです。
- Content Layer APIにより、ファイル・CMS・APIなど任意のデータソースを統一的に管理できるようになった
- Server Islandsにより、静的ページの中にサーバーサイドの動的コンテンツを埋め込めるようになった
- astro:envにより、環境変数を型安全に管理し、クライアント/サーバーの分離を自動化できるようになった
- Vite 6統合により、ビルド速度とHMRが大幅に高速化された
- Astro 4からの移行は段階的に可能で、既存コードの修正は限定的
特にContent Layer APIとServer Islandsの組み合わせは、「パフォーマンスを犠牲にせずにパーソナライズされた体験を提供する」という、これまで両立が難しかった要件を実現可能にします。ECサイト、ブログ、ダッシュボードなど、幅広いユースケースでAstro 5の恩恵を受けることができるでしょう。