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サイトに最適です。