#astro (8)
前提
- Astro 5 で静的サイト生成(
output: 'static') - Cloudflare Pages にデプロイ
- フォーム送信後の webhook 処理が必要
実装パターン
1. Pages Functions の配置
functions/
contact.ts # POST /api/contact
form-utils.ts # 共通ロジック
2. Astro コンポーネント側
---
// src/components/ContactForm.astro
// フォーム validation / UX は astro:islands で React/Svelte
---
<form id="contact-form">
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">送信</button>
</form>
<script>
document.getElementById('contact-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const response = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData))
});
// ...
});
</script>
3. Pages Functions で webhook 実行
// functions/contact.ts
export const onRequest: PagesFunction = async (context) => {
try {
const { name, email, message } = await context.request.json();
// Validation
if (!name || !email || !message) {
return new Response('Missing fields', { status: 400 });
}
// Webhook 実行
const slackResponse = await fetch(
context.env.SLACK_WEBHOOK_URL,
{
method: 'POST',
body: JSON.stringify({
text: `新規お問い合わせ\n名前: ${name}\nメール: ${email}\nメッセージ: ${message}`
})
}
);
if (!slackResponse.ok) {
throw new Error('Slack webhook failed');
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Form submission error:', error);
return new Response('Internal server error', { status: 500 });
}
};
ポイント
- 環境変数:
wrangler.tomlでenvセクションに Slack Webhook URL を定義 - CORS: Astro 静的ファイルとエッジ関数は同一オリジン(CORS 不要)
- キャッシング: Pages Functions は自動的にキャッシュされない(毎回実行)
メリット
- サーバーレス・自動スケーリング
- Google Firebase/Cloud Run より簡潔
- Cloudflare Analytics Engine で リクエスト監視可能
概要
Next.js App Router + Firebase → Astro + CF Pages への移行時のフォーム実装パターン。reCAPTCHA v3、Firebase Cloud Functions、HubSpot 埋め込みをどう置き換えるか。
アーキテクチャ変更
| 要素 | Next.js | Astro |
|---|---|---|
| CAPTCHA | reCAPTCHA v3 | Cloudflare Turnstile |
| バックエンド | Firebase Cloud Functions | CF Pages Functions (/api/*) |
| フォーム送信 | Server Action + Firebase | CF API ルート |
| HubSpot | 埋め込み(SSR時にスキップ) | React Island化 |
実装フロー
1. Turnstile の統合
<!-- Astro コンポーネント -->
<div id="turnstile-container"></div>
<script>
import Turnstile from '@/components/Turnstile.tsx';
</script>
<Turnstile />
2. API ルートの実装
// src/pages/api/submit-form.ts
export async function POST({ request }) {
const formData = await request.json();
// Turnstile 検証
const verifyResponse = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: JSON.stringify({
secret: import.meta.env.TURNSTILE_SECRET_KEY,
response: formData.captchaToken
})
});
if (!verifyResponse.ok) {
return new Response(JSON.stringify({ error: 'Captcha failed' }), { status: 400 });
}
// メール送信等の処理
return new Response(JSON.stringify({ success: true }));
}
3. HubSpot 埋め込みの React Island化
// React コンポーネント
export default function HubSpotForm() {
useEffect(() => {
if (window.hbspt) {
window.hbspt.forms.create({
region: 'na1',
portalId: '...',
formId: '...',
target: '#hubspot-form'
});
}
}, []);
return <div id="hubspot-form" />;
}
Astro での使用:
<HubSpotForm client:load />
メリット
- CF Pages はサーバーレス、メンテナンス不要
- Turnstile は無料+プライバシー優先
- Astro + React Island で必要な部分のみインタラクティブ
検討点
- Firebase から CF Functions への認証情報移行
- メール送信ロジックの Durable Objects 利用の検討
- ログ保存先の決定(D1 / Firestore など)
Next.js → Astro 移行でPlaywright VRTを使い、33テストケース(11ページ×3BP)の差分を自動修正するループを回した。得られた知見。
アーキテクチャ: スキル(司令塔)+ エージェント(修正者)
/vrt スキル (orchestrator)
├── pnpm test:vrt → JSON結果パース
├── 失敗ページをグルーピング
├── vrt-fixer エージェント(ページ単位で並列起動)
├── 再テスト → 収束判定
└── 結果レポート
エージェントにはページ名・差分情報(高さ差・ratio)を渡し、Astro側のコードだけを修正させる。
実証済み差異パターン(頻度順)
| パターン | 症状 | 原因 | 修正 |
|---|---|---|---|
| scroll animation属性 | セクション全体が白 | data-anim="fadeUp" → Playwrightでopacity:0のまま | 属性削除 or CSS注入でopacity:1強制 |
Tailwind preflight height:auto | 画像の高さが数px違う | SVGのheight属性がpreflightで上書き | インラインstyleで固定 |
next/image vs <img> | コンテナサイズ差 | Image最適化ラッパーの有無 | CSS注入でvisibility:hidden |
| DOMPurify正規化 | リッチテキストの行間差 | Next.jsはDOMPurifyで空白除去、移行先は生HTML | normalizeRichEditorHtml()で空白除去 |
| alt属性の不一致 | CSS注入が片側のみ有効 | 移行時にalt文字列を変えてしまう | セレクターに両方のalt値を追加 |
whitespace-pre-line | テキスト行数が違う | 移行先でクラスを追加してしまった | 該当クラス削除 |
| クライアントUIの欠落 | インタラクティブ要素がない | Reactコンポーネント未移植 | vanilla JS or island で再実装 |
VRT CSS注入が必須
両サーバーのスクリーンショットに共通CSSを注入して動的要素を安定化する。これがないとテストが不安定になる。
const HIDE_CSS = `
[data-anim] { opacity: 1 !important; transform: none !important; }
.swiper-wrapper, time { visibility: hidden !important; }
`;
await page.addStyleTag({ content: HIDE_CSS });
高さクリッピング戦略
next/imageのsubpixel差が累積して数px〜数十pxの高さ差になる。MAX_HEIGHT_DIFF_PX以内なら短い方にクリップして比較する。これにより寸法不一致エラーを回避しつつ、大きな構造差異は検出できる。
学び
- 共通コンポーネントの差異を最初に潰す(Footer 3px差 → 全33テストに波及)
- 環境変数の設定漏れは最大の偽陽性源(CMS APIキー未設定 → 15テスト失敗)
display:nonevsvisibility:hidden— 前者はレイアウトフローを変える。画像非表示はvisibility:hiddenが安全- VRT CSS注入リストの更新も正当な修正手段。コード修正と並行して使う
問題
astro dev では Cloudflare Pages Functions (/api/* 経由) が動作しない。
解決方法
CF Pages 環境をローカルで再現するには wrangler が必要:
# ビルド
astro build
# CF Pages Functions 付きで起動
wrangler pages dev dist
ポイント
1. シークレット管理
.envファイルは wrangler が読まない.dev.varsファイル を使用する- 形式は
.envと同じ(KEY=value)
# .dev.vars の例
TURNSTILE_SECRET_KEY=1x0000000000000000000AA
SLACK_WEBHOOK_URL=https://hooks.slack.com/...
2. 既知の警告(無視してOK)
WebSocket関連のエラー:
TypeError: Web Socket request did not return status 101
→ miniflare の既知問題。ローカル開発に影響なし。
3. 使い分け
| 目的 | コマンド |
|---|---|
| 単純な Astro サイト | astro dev |
| API ルートも含む | wrangler pages dev dist |
ベストプラクティス
開発時は wrangler pages dev dist 一択で、CF Pages 本番環境と同じ条件でテストする。
問題
Astro のページファイル(.astro)でフロントマター内に定義した定数を、export async function getStaticPaths() から参照すると ReferenceError が発生する。
---
const ITEMS_PER_PAGE = 10;
export async function getStaticPaths() {
const pageSize = ITEMS_PER_PAGE; // ❌ ReferenceError
// ...
}
---
原因
getStaticPaths() はビルド時に別のスコープで実行されるため、フロントマターで定義した定数にアクセスできない。
解決策
パターン1: getStaticPaths() 内で定義
---
export async function getStaticPaths() {
const ITEMS_PER_PAGE = 10; // ✓ ローカルで定義
// ...
}
---
パターン2: 外部ファイルからインポート
// src/config.ts
export const ITEMS_PER_PAGE = 10;
---
import { ITEMS_PER_PAGE } from '../config';
export async function getStaticPaths() {
const pageSize = ITEMS_PER_PAGE; // ✓ インポートされた定数
// ...
}
---
デバッグのコツ
getStaticPaths() の catch でエラーを握り潰すと、ページが0件生成される(404)という症状になり、原因特定が困難。最低限ログ出力を:
export async function getStaticPaths() {
try {
// ...
} catch (error) {
console.error('getStaticPaths failed:', error); // 必ず出力
return [];
}
} Astro 5ではContent Collectionsの設定が大きく変わった。
変更点
- 設定ファイル:
src/content/config.ts→src/content.config.ts type: "content"が廃止され、loaderが必須に
// Astro 5
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
}),
});
renderも変更
// 旧: post.render()
// 新: import { render } from "astro:content"
import { render } from "astro:content";
const { Content } = await render(post);
post.slug も廃止され、post.id を使う。
現状技術スタック
フロントエンド: Next.js 14.2.35 (App Router) + React 18.3.1 + TypeScript 5.x スタイリング: Tailwind CSS v3 + SCSS Module(microCMSリッチテキスト用) 状態管理: Zustand(2ストア) CMS: microCMS BaaS: Firebase(Firestore, Functions, Auth) デプロイ: Google App Engine
ページ構成: 約50ページ(LeanGoコーポレート、dejamサービス、ad-future)、動的ルート多数
移行のネック項目
| 項目 | 影響度 | 課題 | 解決策 |
|---|---|---|---|
| ISR (revalidateTag) | 高 | Next.js固有、microCMS Webhook対応が必要 | Astro APIルートでWebhook受信→オンデマンドリビルド |
| breakpoint検出 | 高 | useLayoutEffect でwindowサイズ判定、全体ローディング | CSSメディアクエリで代替、JS削減可能 |
| Firestoreクライアント | 中 | /dejam/website/ のみ | client:load Island に分離 |
| Firebase Functions | 中 | httpsCallable(‘sendMail’) | Astro APIエンドポイント or SSRで代替 |
| HubSpotフォーム | 中 | クライアント専用インタラクション | Astro Componentで Island化 |
Astro採用の優位性
Astro Islands パターン:
- ページ全体をJSで制御せず、インタラクティブ必須部分のみを「島」として独立
- ページの大部分は静的HTMLで高速配信、フォーム/カルーセル等のみJS送信
Next.jsと比較した利点:
- ファイル形式がHTML的 → デザイナーが読める
- 概念が少ない → HTML + Tailwind で完結、React hooks/Server-Client判断不要
- Claude Code との相性 → 生成コードがシンプル、副作用少、スコープ局所
- 役割分離が自然 → 静的部分(デザイナー) vs Islands/API(エンジニア)
- サイト特性に合致 → 90%以上が静的 → ゼロJS配信で最高速
データフェッチング戦略
- microCMS: Content Collections + ISR相当(オンデマンドビルド)
- JSON: readFileSync → Astro loaders に置換
- Firestore: Client Islands のみ(useEffect内で直接呼び出し)
- Firebase Functions: Astro SSR APIエンドポイントに移行
デザイナー・エンジニア作業領域分離
| 領域 | 対象 | 担当 |
|---|---|---|
| 静的マークアップ + Tailwind | ほぼ全ページ | デザイナー(.astro) |
| Islands(React/Svelte) | フォーム、カルーセル、Firestore | エンジニア |
| API, デプロイ, CMS連携 | 機能実装 | エンジニア |
リスク・注意点
- テスト未整備 → 移行時のリグレッション検知は手動確認に依存
- GAEデプロイ対応 → Node adapter 検証が必須
- breakpoint検出の移行 → CSSメディアクエリ設計の見直し
結論
大半が静的生成可能で、クライアントJS が限定的。Astro Islands は本件の要件と完全マッチ。ビルドキャッシング戦略の設計が鍵。
概要
Phase 5.8で React 19 を完全脱却し、19コンポーネントを Astro + vanilla JS / Svelte 5 に移植。削除パッケージ数:10+、バンドルサイズ削減:30% 以上。
変換パターン別の実装解説
パターン1: React の状態管理 → data-* 属性 + vanilla JS
React の useState + useEffect を以下のように置き換え:
Before(React):
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
}, [isOpen]);
After(Astro + vanilla JS):
<button id="sp-menu-toggle" aria-expanded="false">...</button>
<nav id="sp-menu-overlay" class="hidden" />
<script>
const toggle = document.getElementById('sp-menu-toggle');
const overlay = document.getElementById('sp-menu-overlay');
let isOpen = false;
toggle.addEventListener('click', () => {
isOpen = !isOpen;
if (isOpen) {
overlay.classList.remove('hidden');
overlay.classList.add('flex');
document.body.style.overflow = 'hidden';
} else {
overlay.classList.add('hidden');
document.body.style.overflow = '';
}
});
</script>
Key points:
- Props を
data-*属性で受け渡し(const formId = container.dataset.formId) - クロージャで状態管理(モジュールスコープの
let変数) document.addEventListener('astro:after-swap', initFunction)で View Transitions 対応
パターン2: React hooks → vanilla JS(body scroll lock, IntersectionObserver)
useBodyScrollLock の置き換え:
function lockScroll(): void {
document.documentElement.style.overflowY = 'hidden';
document.body.style.overflowY = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
function unlockScroll(): void {
document.documentElement.style.overflowY = '';
document.body.style.overflowY = '';
document.body.style.position = '';
document.body.style.width = '';
}
IntersectionObserver の直接使用:
// 追従CTA: FV要素が画面外に出たら表示
const container = document.getElementById('dejam-following-cta');
const target = document.getElementById('first-view');
window.addEventListener('scroll', () => {
const { bottom } = target.getBoundingClientRect();
if (bottom < 0) {
container.classList.remove('hidden');
container.classList.add('flex');
} else {
container.classList.add('hidden');
container.classList.remove('flex');
}
}, { passive: true });
パターン3: embla-carousel-react → vanilla embla-carousel
useEmblaCarousel の廃止:
<div class="carousel">
<div class="carousel__viewport">
<ul class="carousel__container">
{imgs.map((src) => <li><img src={src} /></li>)}
</ul>
</div>
<ul class="carousel__dots">
{imgs.map((_, i) => <li class="dot" data-index={i} />)}
</ul>
</div>
<script>
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
function initCarousel(root) {
const viewport = root.querySelector('.carousel__viewport');
const embla = EmblaCarousel(viewport, { loop: true }, [Autoplay()]);
const dots = [...root.querySelectorAll('.dot')];
function updateDots() {
const selected = embla.selectedScrollSnap();
dots.forEach((dot, i) => {
dot.classList.toggle('bg-primary', i === selected);
});
}
embla.on('select', updateDots);
dots.forEach((dot) => {
dot.addEventListener('click', () => {
const index = Number(dot.dataset.index);
embla.scrollTo(index);
});
});
}
document.querySelectorAll('.carousel').forEach(initCarousel);
</script>
Key points:
EmblaCarousel(viewport, options, plugins)で直接初期化embla.on('select', callback)でイベントリスナー登録embla.scrollTo(index)/scrollPrev()/scrollNext()で操作
パターン4: 複雑な UI 状態管理(PC/SP ヘッダー)
Zustand store → クロージャ変数:
function initDejamHeaderPC() {
const wrapper = document.querySelector('[data-dejam-header-pc]');
const dropdownPanels = wrapper.querySelectorAll('[data-dropdown-panel]');
const navTriggers = wrapper.querySelectorAll('[data-nav-trigger]');
let activeDropdownId = ''; // ← クロージャで状態管理
function showDropdown(id) {
activeDropdownId = id;
dropdownPanels.forEach((panel) => {
if (panel.dataset.dropdownPanel === id) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
updateNavStyles();
}
function closeDropdown() {
activeDropdownId = '';
dropdownPanels.forEach((p) => p.classList.add('hidden'));
updateNavStyles();
}
// mouseenter / mouseleave リスナー
navTriggers.forEach((trigger) => {
trigger.addEventListener('mouseenter', () => {
showDropdown(trigger.dataset.navTrigger);
});
});
wrapper.addEventListener('mouseleave', closeDropdown);
}
initDejamHeaderPC();
document.addEventListener('astro:after-swap', initDejamHeaderPC);
SP メニューのネストされたアコーディオン:
// 各ドロップダウントリガーをループで登録
dropdownTriggers.forEach((trigger) => {
trigger.addEventListener('click', () => {
const dropdownId = trigger.dataset.spDropdownTrigger;
const content = root.querySelector(`[data-sp-dropdown-content="${dropdownId}"]`);
const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
content.classList.add('hidden');
trigger.setAttribute('aria-expanded', 'false');
icon.src = '/images/icon/icon_plus_primary.svg';
} else {
content.classList.remove('hidden');
trigger.setAttribute('aria-expanded', 'true');
icon.src = '/images/icon/icon_minus_primary.svg';
}
});
});
パターン5: react-hook-form → Svelte 5 Runes
React フォーム複雑化時は Svelte 5 を採用。理由:Runes による洗練された状態管理。
Svelte 5 の $state / $derived:
<script>
import { z } from 'zod';
// Props
const { turnstileSiteKey } = $props();
// State
let companyName = $state('');
let email = $state('');
let fieldErrors = $state({});
let isSubmitting = $state(false);
// Derived(自動再計算)
const formData = $derived({
companyName,
email,
// ...
});
const isButtonDisabled = $derived(
isSubmitting || !turnstileToken || !termCheckbox
);
// Validation(immutable pattern)
function validateField(fieldId) {
const result = contactSchema.safeParse(formData);
if (result.success) {
const { [fieldId]: _, ...rest } = fieldErrors;
fieldErrors = rest; // 新オブジェクト生成
return;
}
fieldErrors = { ...fieldErrors, [fieldId]: issue.message };
}
// Event handlers
function handleInput(fieldId, event) {
const target = event.target;
switch (fieldId) {
case 'companyName': companyName = target.value; break;
case 'email': email = target.value; break;
}
if (touched[fieldId]) validateField(fieldId);
}
async function handleSubmit(event) {
event.preventDefault();
if (!validateAllFields()) return;
isSubmitting = true;
try {
await sendContact({ companyName, email }, turnstileToken);
window.location.href = '/complete/';
} finally {
isSubmitting = false;
}
}
</script>
<form onsubmit={handleSubmit}>
{#each FORM_FIELDS as field}
<input
value={companyName}
oninput={(e) => handleInput('companyName', e)}
onblur={() => handleBlur('companyName')}
/>
{#if fieldErrors.companyName}
<p>{fieldErrors.companyName}</p>
{/if}
{/each}
<button disabled={isButtonDisabled} type="submit">送信</button>
</form>
Why Svelte for forms:
$state+$derivedで React のuseState+useMemoを1行で表現oninput/onchangeで直接バインディング(event delegation 不要)- Zod スキーマとの統合が簡潔
共通の工夫ポイント
1. astro:after-swap 対応(View Transitions)
function init() { /* 初期化処理 */ }
init();
document.addEventListener('astro:after-swap', init);
ClientRouter でページ遷移時も再初期化が必要。
2. esbuild パースエラー回避
フロントマター内で HTML タグを使わない:
---
// これは NG(dev 時 esbuild がパースエラー)
/**
* <script>alert('xss')</script>
*/
// これは OK(// コメント使用)
// - ドロップダウン表示
// - hover イベント管理
---
3. data-* 属性で Props 受け渡し
<div data-carousel data-autoplay="true" data-loop="false">
...
</div>
<script>
const carousel = document.querySelector('[data-carousel]');
const autoplay = carousel.dataset.autoplay === 'true';
const loop = carousel.dataset.loop === 'true';
</script>
4. client: ディレクティブ不要
vanilla JS は自動的にクライアント実行。<script is:inline> で SSR をスキップ可能。
削除した npm パッケージ
react 19
react-dom 19
@react-aria/*(17 dependency)
embla-carousel-react → embla-carousel に統一
zustand(2つの store のみ利用、残すのは検討中)
バンドルサイズ削減:
- Before: ~180KB (gzipped)
- After: ~125KB (gzipped)
- 削減: 30%
実装チェックリスト
各コンポーネント移植時:
- data-* 属性で Props 受け渡し
-
astro:after-swapイベントリスナー登録 - TypeScript 型定義(
HTMLElementなど) - null guard で TS narrowing(クロージャ内で確実に)
-
classListで class 切り替え(immutable pattern) - aria-* 属性で a11y 対応
-
{ passive: true }で scroll リスナー最適化
まとめ
| React | vanilla JS / Svelte |
|---|---|
useState | クロージャ変数 / $state |
useEffect | addEventListener / onMount |
useMemo | $derived |
| Props | data-* 属性 + dataset |
| Store(Zustand) | クロージャ変数 |
| Hook(embla) | 直接 SDK 呼び出し |
| Event delegation | 直接 addEventListener |
結論: React を脱却することで、ビルド時間短縮・バンドルサイズ削減・保守性向上を同時実現。Astro + vanilla JS は小規模な UI インタラクションに最適。複雑な state 管理が必要な場合のみ Svelte 5 を検討。