使用するタイミング
- ランディングページやマーケティングサイト
- コーポレートサイト
- コンテンツが豊富なサイト(ブログ、ドキュメント)
- SEO とパフォーマンスが重要なプロジェクト
- ほとんどが静的で、インタラクティブ性が最小限のサイト
- 初回読み込みが速く、Core Web Vitals を満たす必要があるサイト
本格的な Web アプリケーションには非推奨 — インタラクティブなアプリケーション(ダッシュボード、CRUD アプリ、リアルタイムアプリ)には vue-frontend を使用してください。
重要なルール
- デフォルトでゼロ JS — Astro は
client:*ディレクティブを追加しない限り、JavaScript を一切出力しません - 常に TypeScript — 他の Vera Sesiom プロジェクトと同様、strict モード
- 構造化コンテンツには Content Collections を使用 — ブログ記事、ドキュメント、ケーススタディ
- Islands アーキテクチャ — インタラクティブ性が必要な箇所にのみ Vue/React コンポーネントを使用
- デフォルトで静的出力(SSG) — SSR は動的コンテンツが必要な場合のみ
- スタイリングには Tailwind CSS — Vue フロントエンドと同じ
- Screaming Architecture — フォルダ構造はフレームワークではなく、ドメインを表現する
判断基準:Astro vs Vue
| Astro を使用する場合 | Vue を使用する場合 |
|---|---|
| ランディングページ、マーケティングサイト | Web アプリケーション(ダッシュボード、管理画面) |
| コンテンツが豊富(ブログ、ドキュメント) | 高度にインタラクティブな UI |
| SEO が重要 | リアルタイム機能が必要 |
| ほとんどが静的なコンテンツ | 複雑な状態管理 |
| 最小限のインタラクティブ性(フォーム、カルーセル) | フル SPA ナビゲーション |
| ゼロ JS が利点となる | Vue エコシステムのメリットが活きる |
ルール: サイトが人々が読むコンテンツなら → Astro。人々が使うアプリなら → Vue。
プロジェクト構造(Screaming Architecture)
apps/landing/ (または apps/website/)
├── src/
│ ├── pages/ # ファイルベースルーティング(.astro ファイル)
│ │ ├── index.astro # ホームページ
│ │ ├── about.astro # アバウトページ
│ │ ├── pricing.astro # 料金ページ
│ │ ├── blog/
│ │ │ ├── index.astro # ブログ一覧
│ │ │ └── [slug].astro # ブログ記事(動的)
│ │ └── contact.astro
│ ├── layouts/ # ページレイアウト
│ │ ├── BaseLayout.astro # ベース HTML 構造
│ │ ├── BlogLayout.astro # ブログ記事レイアウト
│ │ └── LandingLayout.astro # ランディングページレイアウト
│ ├── components/ # Astro コンポーネント(デフォルトで静的)
│ │ ├── sections/ # ページセクション
│ │ │ ├── Hero.astro
│ │ │ ├── Features.astro
│ │ │ ├── Pricing.astro
│ │ │ ├── Testimonials.astro
│ │ │ └── CTA.astro
│ │ ├── ui/ # 再利用可能な UI アトム
│ │ │ ├── Button.astro
│ │ │ ├── Card.astro
│ │ │ ├── Badge.astro
│ │ │ └── Input.astro
│ │ └── interactive/ # Vue/React アイランド
│ │ ├── ContactForm.vue # バリデーション付きフォーム
│ │ ├── Carousel.vue # 画像カルーセル
│ │ └── MobileMenu.vue # モバイルナビゲーション
│ ├── content/ # コンテンツコレクション
│ │ ├── blog/ # Markdown/MDX ブログ記事
│ │ │ ├── post-1.md
│ │ │ └── post-2.mdx
│ │ └── config.ts # コレクションスキーマ(Zod)
│ ├── styles/ # グローバルスタイル
│ │ └── global.css # Tailwind インポート
│ ├── assets/ # 最適化された画像、フォント
│ │ ├── images/
│ │ └── fonts/
│ └── utils/ # ヘルパー関数
│ ├── date-format.ts
│ └── seo.ts
├── public/ # 静的アセット(処理なし)
│ ├── favicon.ico
│ ├── robots.txt
│ └── fonts/
├── astro.config.mjs # Astro 設定
├── tailwind.config.mjs # Tailwind 設定
├── tsconfig.json
└── package.json
Astro コンポーネントパターン
すべての .astro ファイルは、フロントマター(サーバーサイド)とテンプレート(HTML)の 2 つの部分で構成されます。
---
// src/components/ui/Button.astro
// フロントマター: サーバーサイドで実行、ブラウザ JS はなし
export interface Props {
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
href?: string
}
const {
variant = 'primary',
size = 'md',
href,
...rest
} = Astro.props
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`
---
{href ? (
<a href={href} class={classes} {...rest}>
<slot />
</a>
) : (
<button class={classes} {...rest}>
<slot />
</button>
)}
重要なポイント:
- フロントマターはビルド時(SSR の場合はリクエスト時)に実行されます
export defaultは不要 — テンプレート自体がエクスポートです<slot />で子コンテンツを配置します- Props は TypeScript インターフェースで型付けされます
レイアウトパターン
---
// src/layouts/BaseLayout.astro
import Nav from '../components/Nav.astro'
import Footer from '../components/Footer.astro'
export interface Props {
title: string
description?: string
image?: string
}
const { title, description = '', image = '/og-default.png' } = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href={canonicalURL} />
<title>{title}</title>
<meta name="description" content={description} />
<!-- OpenGraph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:url" href={canonicalURL} />
<meta property="og:type" content="website" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<slot name="head" />
</head>
<body class="bg-white text-gray-900 antialiased">
<Nav />
<main>
<slot />
</main>
<Footer />
</body>
</html>
使用例:
---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import Hero from '../components/sections/Hero.astro'
---
<BaseLayout title="Vera Sesiom - Software Development" description="We build software that matters.">
<Hero />
</BaseLayout>
コンテンツコレクションパターン
Zod スキーマのバリデーションで構造化コンテンツを定義します。
// src/content/config.ts
import { defineCollection, z } from 'astro:content'
const blogCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('Vera Sesiom'),
image: z.object({
url: z.string(),
alt: z.string()
}).optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false)
})
})
export const collections = {
blog: blogCollection
}
---
title: "Why We Chose Astro for Landing Pages"
description: "Performance and developer experience matter. Here's why Astro is our go-to for content sites."
pubDate: 2024-01-15
author: "Tech Lead"
image:
url: "/images/blog/astro-landing.jpg"
alt: "Astro logo on a modern website"
tags: ["astro", "performance", "architecture"]
---
Content goes here...
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
const posts = (await getCollection('blog'))
.filter(post => !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
---
<BaseLayout title="Blog" description="Latest articles from Vera Sesiom">
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</a>
</li>
))}
</ul>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.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 } = await post.render()
---
<BaseLayout title={post.data.title} description={post.data.description}>
<article>
<header>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<p>By {post.data.author}</p>
</header>
<Content />
</article>
</BaseLayout>
Islands アーキテクチャ(Vue コンポーネント)
インタラクティブなコンポーネントは、必要な場合にのみ JS を出力します。クライアントディレクティブを使用してハイドレーションを制御します。
<!-- src/components/interactive/ContactForm.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const form = ref({
name: '',
email: '',
message: ''
})
const errors = ref<Record<string, string>>({})
const isValid = computed(() => {
return form.value.name &&
form.value.email.includes('@') &&
form.value.message.length > 10
})
async function submitForm() {
if (!isValid.value) return
// API に送信
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value)
})
if (response.ok) {
// 成功時の処理
form.value = { name: '', email: '', message: '' }
}
}
</script>
<template>
<form @submit.prevent="submitForm" class="space-y-4">
<div>
<label for="name">Name</label>
<input v-model="form.name" type="text" id="name" required />
</div>
<div>
<label for="email">Email</label>
<input v-model="form.email" type="email" id="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea v-model="form.message" id="message" rows="4" required />
</div>
<button type="submit" :disabled="!isValid">
Send Message
</button>
</form>
</template>
Astro での使用例:
---
// src/pages/contact.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import ContactForm from '../components/interactive/ContactForm.vue'
---
<BaseLayout title="Contact Us">
<h1>Get in Touch</h1>
<p>We'd love to hear from you.</p>
<!-- client:visible = スクロールして表示されたときにハイドレート(ベストプラクティス) -->
<ContactForm client:visible />
</BaseLayout>
クライアントディレクティブの判断基準
| ディレクティブ | 使用するタイミング | JS の出力タイミング |
|---|---|---|
| ディレクティブなし | 静的なコンテンツ、インタラクティブ性なし | JS ゼロ |
client:visible | スクロールしないと見えないインタラクティブ要素(フォーム、カルーセル)— デフォルト | スクロールして表示されたとき |
client:load | ファーストビューで重要なインタラクティブ要素 | すぐに |
client:idle | 重要でない拡張機能(アナリティクス、チャット) | ページがアイドル状態になった後 |
client:media | ブレークポイントによる条件付き(モバイルナビ) | メディアクエリに一致したとき |
client:only | SPA 的なコンポーネント(SSR をスキップ) | すぐに、SSR なし |
ベストプラクティス:
- デフォルトは ディレクティブなし — 純粋な静的
- インタラクティブ性が必要な場合はデフォルトで
client:visible client:loadは控えめに — ファーストビューで重要なもののみ- ブロッキングしない拡張機能には
client:idleを使用
---
<!-- 良い例 -->
import Hero from './Hero.astro' <!-- 静的、JS なし -->
import Carousel from './Carousel.vue'
import MobileMenu from './MobileMenu.vue'
import Chat from './Chat.vue'
import Stats from './Stats.astro' <!-- 静的なカウンター = CSS -->
<Hero /> <!-- 静的、ハイドレーションなし -->
<Carousel client:visible /> <!-- 表示されたときにハイドレート -->
<MobileMenu client:media="(max-width: 768px)" /> <!-- モバイルのみ -->
<Chat client:idle /> <!-- ページが落ち着いた後 -->
<Stats /> <!-- 純粋な CSS アニメーション、JS 不要 -->
画像最適化
自動最適化には astro:assets を使用します。
---
// src/pages/index.astro
import { Image } from 'astro:assets'
import heroImage from '../assets/images/hero.png'
---
<Image
src={heroImage}
alt="Hero image"
width={800}
height={600}
loading="lazy"
format="webp"
/>
CMS や外部ソースからの画像の場合:
---
import { Image } from 'astro:assets'
---
<Image
src="https://example.com/image.jpg"
alt="External image"
width={800}
height={600}
/>
ルール:
- CLS 防止のため、常に
widthとheightを指定 - ファーストビューより下の画像には
loading="lazy"を使用 webpまたはavif形式を推奨- ヒーロー画像は最適化し、適切なサイズにすること
SEO パターン
---
// src/components/SEO.astro
export interface Props {
title: string
description: string
image?: string
article?: boolean
publishedTime?: Date
author?: string
tags?: string[]
}
const {
title,
description,
image = '/og-default.png',
article = false,
publishedTime,
author,
tags = []
} = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const imageURL = new URL(image, Astro.site)
---
<!-- プライマリメタタグ -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:type" content={article ? 'article' : 'website'} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageURL} />
<!-- 記事特有のタグ -->
{article && publishedTime && (
<meta property="article:published_time" content={publishedTime.toISOString()} />
)}
{article && author && (
<meta property="article:author" content={author} />
)}
{article && tags.map(tag => (
<meta property="article:tag" content={tag} />
))}
<!-- JSON-LD 構造化データ -->
{article && publishedTime && (
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description: description,
image: imageURL.toString(),
author: {
'@type': 'Person',
name: author
},
datePublished: publishedTime.toISOString()
})} />
)}
Astro.config.mjs
// astro.config.mjs
import { defineConfig } from 'astro/config'
import tailwind from '@astrojs/tailwind'
import vue from '@astrojs/vue'
import sitemap from '@astrojs/sitemap'
// https://astro.build/config
export default defineConfig({
site: 'https://verasesiom.com',
output: 'static',
integrations: [
tailwind(),
vue(),
sitemap()
],
build: {
inlineStylesheets: 'auto'
},
image: {
service: {
entrypoint: 'astro/assets/services/sharp'
}
}
})
環境変数:
# .env
PUBLIC_SITE_URL=https://verasesiom.com
Vue アイランド:特別な考慮事項
Vue コンポーネントをアイランドとして使用する場合:
client:*ディレクティブを常に含める(そうでないと JS は出力されない)- 状態管理には Pinia ではなく
ref()/reactive()を使用(アイランドは独立しているため) provide/injectはアイランド境界を越えない — Props のみ- アイランド内のルーティングは不要(ページ遷移には Astro ナビゲーションを使用)
- Vue の
<Teleport>は期待通りに動作しない場合があるため、注意して使用
---
// src/pages/index.astro
import ProductCard from '../components/interactive/ProductCard.vue'
import NewsletterForm from '../components/interactive/NewsletterForm.vue'
import Analytics from '../components/interactive/Analytics.vue'
---
<main>
<ProductCard client:visible />
<NewsletterForm client:load />
<Analytics client:idle />
</main>
感動的な Astro プロジェクト向けベストプラクティス
Astro.glob()ではなくgetCollection()を使用 — 型安全でスキーマ検証が可能- 部分的なハイドレーションには常に
client:visibleを優先 — デフォルトの選択肢 - コンポーネントをアトミックに保つ — 1 つのコンポーネントで 1 つのこと
- Screaming Architecture を維持する — フォルダ構造がドメインを表現する
<style>ブロックのスコープがデフォルト — グローバルには Tailwind を使用- パフォーマンスのために
ViewTransitionsを使用 — スムーズなページ遷移、JS 最小限 <Image />コンポーネントで画像を自動最適化 —widthとheightを必須に- RSS フィードには
@astrojs/rssを使用 — ブログやコンテンツサイトに
// astro.config.mjs
import { defineConfig } from 'astro/config'
import tailwind from '@astrojs/tailwind'
import vue from '@astrojs/vue'
import sitemap from '@astrojs/sitemap'
import rss from '@astrojs/rss'
export default defineConfig({
site: 'https://verasesiom.com',
output: 'static',
integrations: [
tailwind(),
vue(),
sitemap()
],
build: {
inlineStylesheets: 'auto'
},
image: {
service: {
entrypoint: 'astro/assets/services/sharp'
}
}
})