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サイトの構築体験を大幅に向上させるアップデートです。

主なポイントは以下のとおりです。

  1. Content Layer APIにより、ファイル・CMS・APIなど任意のデータソースを統一的に管理できるようになった
  2. Server Islandsにより、静的ページの中にサーバーサイドの動的コンテンツを埋め込めるようになった
  3. astro:envにより、環境変数を型安全に管理し、クライアント/サーバーの分離を自動化できるようになった
  4. Vite 6統合により、ビルド速度とHMRが大幅に高速化された
  5. Astro 4からの移行は段階的に可能で、既存コードの修正は限定的

特にContent Layer APIとServer Islandsの組み合わせは、「パフォーマンスを犠牲にせずにパーソナライズされた体験を提供する」という、これまで両立が難しかった要件を実現可能にします。ECサイト、ブログ、ダッシュボードなど、幅広いユースケースでAstro 5の恩恵を受けることができるでしょう。

関連記事