Astro 4完全ガイド - コンテンツコレクションとアイランドアーキテクチャで構築する次世代Webサイト


Astro 4は、静的サイト生成(SSG)とコンテンツ管理の分野で革新的なフレームワークです。コンテンツコレクションアイランドアーキテクチャにより、パフォーマンスと開発者体験を両立させた次世代のWebサイト構築を実現します。

この記事では、Astro 4の主要機能を実践的に解説し、実際のプロジェクトで活用できる知識を提供します。

Astro 4とは何か

Astroは「コンテンツ重視のWebサイト」に特化したフレームワークで、以下の特徴を持ちます。

主要な特徴

// デフォルトでゼロJavaScript
// 必要な箇所だけReact/Vue/Svelteを埋め込み可能
---
import { getCollection } from 'astro:content';
import BlogCard from '../components/BlogCard.astro';

const posts = await getCollection('blog');
---

<main>
  {posts.map(post => <BlogCard post={post} />)}
</main>

パフォーマンス優先: デフォルトでJavaScriptを送信せず、必要な箇所だけハイドレーション可能 フレームワーク非依存: React、Vue、Svelte、Solidなど複数のフレームワークを混在可能 コンテンツ管理特化: TypeSafe なコンテンツコレクションAPI ビルド高速化: Viteベースの高速な開発体験

コンテンツコレクション完全ガイド

Astro 4の最大の特徴であるコンテンツコレクションは、Markdown/MDXファイルを型安全に管理する仕組みです。

コンテンツコレクションの基本設定

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

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

const docsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string().optional(),
    order: z.number().default(999),
    category: z.enum(['guide', 'api', 'tutorial', 'reference']),
  }),
});

export const collections = {
  blog: blogCollection,
  docs: docsCollection,
};

型安全なコンテンツ取得

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

// 型安全にコレクション取得
const allPosts = await getCollection('blog');

// フィルタリング(draft除外、日付順ソート)
const publishedPosts = allPosts
  .filter(post => !post.data.draft)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

// タグ別にグループ化
const postsByTag = publishedPosts.reduce((acc, post) => {
  post.data.tags.forEach(tag => {
    if (!acc[tag]) acc[tag] = [];
    acc[tag].push(post);
  });
  return acc;
}, {} as Record<string, CollectionEntry<'blog'>[]>);
---

<div class="blog-index">
  {publishedPosts.map(post => (
    <article>
      <a href={`/blog/${post.slug}/`}>
        <h2>{post.data.title}</h2>
        <time datetime={post.data.pubDate.toISOString()}>
          {post.data.pubDate.toLocaleDateString('ja-JP')}
        </time>
        <p>{post.data.description}</p>
        <div class="tags">
          {post.data.tags.map(tag => (
            <span class="tag">{tag}</span>
          ))}
        </div>
      </a>
    </article>
  ))}
</div>

動的ルート生成

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

export async function getStaticPaths() {
  const posts = await getCollection('blog');

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

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

<BlogLayout
  title={post.data.title}
  description={post.data.description}
  pubDate={post.data.pubDate}
  author={post.data.author}
  headings={headings}
>
  <Content />
</BlogLayout>

データコレクション(JSON/YAML対応)

// src/content/config.ts
const authorsCollection = defineCollection({
  type: 'data', // JSON/YAML用
  schema: z.object({
    name: z.string(),
    bio: z.string(),
    avatar: z.string().url(),
    twitter: z.string().optional(),
    github: z.string().optional(),
  }),
});

export const collections = {
  blog: blogCollection,
  authors: authorsCollection,
};
# src/content/authors/john-doe.yaml
name: John Doe
bio: Full-stack developer and open source enthusiast
avatar: https://example.com/avatar.jpg
twitter: johndoe
github: johndoe
// 使用例
import { getEntry } from 'astro:content';

const author = await getEntry('authors', 'john-doe');
// author.data.name は型安全

アイランドアーキテクチャの実践

Astroのアイランドアーキテクチャは、ページの大部分を静的HTMLにしつつ、必要な箇所だけインタラクティブにする設計パターンです。

アイランドの基本

---
// src/pages/product.astro
import StaticHeader from '../components/StaticHeader.astro';
import InteractiveCarousel from '../components/InteractiveCarousel.jsx';
import StaticFooter from '../components/StaticFooter.astro';
---

<!-- 静的コンポーネント(JSなし) -->
<StaticHeader />

<main>
  <!-- 静的コンテンツ -->
  <section class="hero">
    <h1>Welcome to Our Product</h1>
  </section>

  <!-- インタラクティブなアイランド -->
  <InteractiveCarousel client:load images={productImages} />

  <!-- 再び静的 -->
  <section class="features">
    <h2>Features</h2>
    <!-- ... -->
  </section>
</main>

<StaticFooter />

クライアントディレクティブ

Astroは5種類のハイドレーション戦略を提供します。

---
import ReactCounter from './ReactCounter.jsx';
import VueChart from './VueChart.vue';
import SvelteModal from './SvelteModal.svelte';
---

<!-- ページロード時にハイドレーション -->
<ReactCounter client:load />

<!-- ビューポートに入ったらハイドレーション -->
<VueChart client:visible />

<!-- ブラウザアイドル時にハイドレーション -->
<SvelteModal client:idle />

<!-- メディアクエリマッチ時にハイドレーション -->
<MobileMenu client:media="(max-width: 768px)" />

<!-- ハイドレーションせず、サーバーレンダリングのみ -->
<StaticReactComponent client:only="react" />

実践例: インタラクティブなブログサイト

// src/components/CommentSection.tsx
import { useState, useEffect } from 'react';

interface Comment {
  id: string;
  author: string;
  content: string;
  createdAt: Date;
}

export default function CommentSection({ postId }: { postId: string }) {
  const [comments, setComments] = useState<Comment[]>([]);
  const [newComment, setNewComment] = useState('');

  useEffect(() => {
    fetch(`/api/comments/${postId}`)
      .then(res => res.json())
      .then(setComments);
  }, [postId]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const response = await fetch('/api/comments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ postId, content: newComment }),
    });

    if (response.ok) {
      const comment = await response.json();
      setComments([...comments, comment]);
      setNewComment('');
    }
  };

  return (
    <section className="comments">
      <h3>コメント ({comments.length})</h3>

      <form onSubmit={handleSubmit}>
        <textarea
          value={newComment}
          onChange={(e) => setNewComment(e.target.value)}
          placeholder="コメントを入力..."
        />
        <button type="submit">送信</button>
      </form>

      <div className="comment-list">
        {comments.map(comment => (
          <article key={comment.id}>
            <strong>{comment.author}</strong>
            <p>{comment.content}</p>
            <time>{new Date(comment.createdAt).toLocaleDateString()}</time>
          </article>
        ))}
      </div>
    </section>
  );
}
---
// src/pages/blog/[slug].astro
import { getEntry } from 'astro:content';
import CommentSection from '../../components/CommentSection.tsx';

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

<article class="blog-post">
  <!-- 静的コンテンツ -->
  <header>
    <h1>{post.data.title}</h1>
    <time>{post.data.pubDate.toLocaleDateString()}</time>
  </header>

  <Content />

  <!-- インタラクティブなコメント機能 -->
  <CommentSection client:visible postId={post.id} />
</article>

View Transitions API

Astro 4はView Transitions APIをサポートし、SPAライクなページ遷移を実現します。

基本的な有効化

---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <slot />
  </body>
</html>

カスタムトランジション

---
import { fade, slide } from 'astro:transitions';
---

<header transition:animate={slide({ duration: '0.3s' })}>
  <nav>...</nav>
</header>

<main transition:animate={fade()}>
  <slot />
</main>

トランジション中の状態管理

// src/components/LoadingIndicator.astro
<script>
  document.addEventListener('astro:before-preparation', () => {
    document.body.classList.add('loading');
  });

  document.addEventListener('astro:after-swap', () => {
    document.body.classList.remove('loading');
  });
</script>

<div class="loading-bar"></div>

<style>
  .loading-bar {
    position: fixed;
    top: 0;
    left: 0;
    height: 3px;
    background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
    width: 0;
    transition: width 0.3s;
  }

  :global(body.loading) .loading-bar {
    width: 100%;
  }
</style>

SSRとハイブリッドレンダリング

Astro 4はSSG(静的生成)だけでなく、SSR(サーバーサイドレンダリング)にも対応しています。

アダプター設定

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

export default defineConfig({
  output: 'server', // or 'hybrid'
  adapter: vercel(),
});

ハイブリッドモード

---
// デフォルトはSSR、個別にプリレンダリング指定
export const prerender = true;

// このページは静的生成される
const data = await fetch('https://api.example.com/static-data').then(r => r.json());
---

<h1>Static Page</h1>
<p>{data.message}</p>
---
// デフォルトはSSR、動的ページ
const userId = Astro.params.id;
const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json());
---

<h1>{user.name}</h1>
<p>このページはリクエストごとに生成されます</p>

APIルート

// src/pages/api/search.ts
import type { APIRoute } from 'astro';
import { getCollection } from 'astro:content';

export const GET: APIRoute = async ({ request }) => {
  const url = new URL(request.url);
  const query = url.searchParams.get('q') || '';

  const posts = await getCollection('blog');

  const results = posts.filter(post =>
    post.data.title.toLowerCase().includes(query.toLowerCase()) ||
    post.data.description.toLowerCase().includes(query.toLowerCase())
  );

  return new Response(JSON.stringify({
    query,
    results: results.map(post => ({
      slug: post.slug,
      title: post.data.title,
      description: post.data.description,
    })),
  }), {
    headers: { 'Content-Type': 'application/json' },
  });
};

実践プロジェクト例

ブログサイトの完全実装

// プロジェクト構造
src/
├── content/
│   ├── config.ts
│   ├── blog/
│   │   ├── first-post.md
│   │   └── second-post.md
│   └── authors/
│       └── john-doe.yaml
├── layouts/
│   ├── BaseLayout.astro
│   └── BlogLayout.astro
├── components/
│   ├── Header.astro
│   ├── BlogCard.astro
│   ├── TagList.astro
│   └── SearchBox.tsx
├── pages/
│   ├── index.astro
│   ├── blog/
│   │   ├── index.astro
│   │   ├── [slug].astro
│   │   └── tag/[tag].astro
│   └── api/
│       └── search.ts
└── styles/
    └── global.css
---
// src/pages/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import BlogCard from '../components/BlogCard.astro';

const allPosts = await getCollection('blog');
const featuredPosts = allPosts
  .filter(post => post.data.featured && !post.data.draft)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
  .slice(0, 3);

const recentPosts = allPosts
  .filter(post => !post.data.draft)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
  .slice(0, 6);
---

<BaseLayout title="TechBlog - 最新の技術情報をお届け">
  <section class="hero">
    <h1>最新の技術トピックス</h1>
    <p>開発者のための実践的な情報を発信</p>
  </section>

  <section class="featured">
    <h2>注目の記事</h2>
    <div class="featured-grid">
      {featuredPosts.map(post => (
        <BlogCard post={post} featured />
      ))}
    </div>
  </section>

  <section class="recent">
    <h2>最新記事</h2>
    <div class="post-grid">
      {recentPosts.map(post => (
        <BlogCard post={post} />
      ))}
    </div>
  </section>
</BaseLayout>
---
// src/components/BlogCard.astro
import type { CollectionEntry } from 'astro:content';

interface Props {
  post: CollectionEntry<'blog'>;
  featured?: boolean;
}

const { post, featured = false } = Astro.props;
const { title, description, pubDate, tags } = post.data;
---

<article class:list={['blog-card', { featured }]}>
  <a href={`/blog/${post.slug}/`}>
    <header>
      <h3>{title}</h3>
      <time datetime={pubDate.toISOString()}>
        {pubDate.toLocaleDateString('ja-JP', {
          year: 'numeric',
          month: 'long',
          day: 'numeric',
        })}
      </time>
    </header>

    <p class="description">{description}</p>

    <footer>
      <div class="tags">
        {tags.map(tag => (
          <span class="tag">{tag}</span>
        ))}
      </div>
      <span class="read-more">続きを読む →</span>
    </footer>
  </a>
</article>

<style>
  .blog-card {
    border: 1px solid #e5e7eb;
    border-radius: 8px;
    padding: 1.5rem;
    transition: transform 0.2s, box-shadow 0.2s;
  }

  .blog-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
  }

  .blog-card.featured {
    border-color: #667eea;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
  }

  .blog-card a {
    text-decoration: none;
    color: inherit;
  }

  .tags {
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
  }

  .tag {
    font-size: 0.875rem;
    padding: 0.25rem 0.75rem;
    background: rgba(0, 0, 0, 0.05);
    border-radius: 4px;
  }

  .featured .tag {
    background: rgba(255, 255, 255, 0.2);
  }
</style>

タグページの実装

---
// src/pages/blog/tag/[tag].astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import BlogCard from '../../../components/BlogCard.astro';

export async function getStaticPaths() {
  const allPosts = await getCollection('blog');
  const allTags = [...new Set(allPosts.flatMap(post => post.data.tags))];

  return allTags.map(tag => ({
    params: { tag },
    props: {
      posts: allPosts
        .filter(post => post.data.tags.includes(tag))
        .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
    },
  }));
}

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

<BaseLayout title={`"${tag}" タグの記事`}>
  <header class="tag-header">
    <h1>#{tag}</h1>
    <p>{posts.length}件の記事</p>
  </header>

  <div class="post-grid">
    {posts.map(post => (
      <BlogCard post={post} />
    ))}
  </div>
</BaseLayout>

パフォーマンス最適化

画像最適化

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.jpg';
---

<!-- 自動最適化・遅延ロード・レスポンシブ -->
<Image
  src={heroImage}
  alt="Hero"
  width={1200}
  height={600}
  loading="lazy"
  format="webp"
/>

プリフェッチ

---
import { prefetch } from 'astro:prefetch';
---

<nav>
  <a href="/blog" data-astro-prefetch>ブログ</a>
  <a href="/about" data-astro-prefetch>About</a>
</nav>

バンドルサイズ削減

// astro.config.mjs
export default defineConfig({
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            'react-vendor': ['react', 'react-dom'],
            'ui-components': ['./src/components/ui'],
          },
        },
      },
    },
  },
});

MDXの高度な活用

---
// src/content/blog/interactive-post.mdx
title: "インタラクティブな記事"
description: "MDXでReactコンポーネントを埋め込む"
pubDate: "2025-02-06"
tags: ["mdx", "react"]
---

import InteractiveChart from '../../components/InteractiveChart.tsx';
import CodeSandbox from '../../components/CodeSandbox.astro';

# インタラクティブな記事

通常のMarkdownテキストに加えて、Reactコンポーネントを埋め込めます。

<InteractiveChart data={[1, 2, 3, 4, 5]} />

## コードサンプル

<CodeSandbox id="react-example" />

{/* JSX式も使える */}
現在時刻: {new Date().toLocaleTimeString()}

TypeScript活用パターン

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

export type BlogPost = CollectionEntry<'blog'>;

export function sortPostsByDate(posts: BlogPost[]): BlogPost[] {
  return posts.sort((a, b) =>
    b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
}

export function filterDraftPosts(posts: BlogPost[]): BlogPost[] {
  return posts.filter(post => !post.data.draft);
}

export function groupPostsByYear(posts: BlogPost[]): Map<number, BlogPost[]> {
  return posts.reduce((acc, post) => {
    const year = post.data.pubDate.getFullYear();
    if (!acc.has(year)) {
      acc.set(year, []);
    }
    acc.get(year)!.push(post);
    return acc;
  }, new Map<number, BlogPost[]>());
}

export function getRelatedPosts(
  currentPost: BlogPost,
  allPosts: BlogPost[],
  limit = 3
): BlogPost[] {
  const currentTags = new Set(currentPost.data.tags);

  return allPosts
    .filter(post => post.id !== currentPost.id)
    .map(post => ({
      post,
      matchCount: post.data.tags.filter(tag => currentTags.has(tag)).length,
    }))
    .filter(({ matchCount }) => matchCount > 0)
    .sort((a, b) => b.matchCount - a.matchCount)
    .slice(0, limit)
    .map(({ post }) => post);
}

デプロイ

Vercelへのデプロイ

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

export default defineConfig({
  output: 'static',
  adapter: vercel({
    webAnalytics: { enabled: true },
  }),
});

Cloudflare Pagesへのデプロイ

import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
});

まとめ

Astro 4は以下の点で優れています。

パフォーマンス: デフォルトゼロJavaScriptで超高速 開発者体験: TypeSafe なコンテンツコレクション、Viteベースの高速ビルド 柔軟性: 複数フレームワーク混在可能、SSG/SSR/ハイブリッド対応 コンテンツ管理: Markdown/MDX/JSON/YAMLを統一的に扱える

特にブログ、ドキュメントサイト、コーポレートサイトなどコンテンツ重視のWebサイトに最適です。

参考リンク