前提
- 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 など)
概要
reCAPTCHA v3 から Cloudflare Turnstile へ移行する際の実装パターン。Turnstile は無料・プライバシーフレンドリーで CF Pages ネイティブ対応。
キー情報
- テストキー
- サイトキー:
1x00000000000000000000AA - シークレット:
1x0000000000000000000AA
- サイトキー:
- 本番環境: Cloudflare Dashboard で生成
クライアント側(React/Svelte)
// CDN 読み込み(head に挿入)
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
// Explicit rendering(推奨)
turnstile.render('#turnstile-container', {
siteKey: import.meta.env.PUBLIC_TURNSTILE_KEY,
theme: 'light',
callback: (token) => {
// トークン取得後の処理(フォーム送信など)
console.log('Turnstile token:', token);
}
});
// リセット(フォーム送信後)
turnstile.reset();
サーバー側(CF Pages Functions / Node.js)
// POST /api/verify-captcha
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: token
})
});
const data = await response.json();
if (!data.success) {
throw new Error('Captcha verification failed');
}
メリット
- reCAPTCHA より実装が簡単
- ユーザー側のプライバシー尊重(データ共有なし)
- CF Pages との相性が良い
- 無料で使用可能
課題
未設定のシークレット(Slack Webhook 等)で HTTP 500 エラーが発生し、開発が阻害される。
ベストプラクティス
外部連携は オプション扱い にする。
// ❌ 悪い例:必須として扱う
const webhookUrl = env.SLACK_WEBHOOK_URL; // undefined でクラッシュ
await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(...) });
// ✅ 良い例:条件分岐でスキップ
const webhookUrl = env.SLACK_WEBHOOK_URL;
if (webhookUrl) {
await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(...) });
} else {
console.log('Slack webhook not configured, skipping notification');
}
// 呼び出し側は成功を返す(重要)
return { success: true, message: 'Email sent' };
利点
- 開発環境では Slack 通知なしで動作
- 本番環境では通知される
- 500 エラーがなくなり、開発効率向上
適用対象
- Slack Webhook
- メール送信サービス
- 外部 API 連携
- アナリティクス等のオプション機能
注意
メール送信などの主要機能は必須として扱う。 スキップ対象は「あると便利」な通知程度に留める。
背景
Enterprise フレームワークからエッジコンピューティングへの移行時、既存の Firebase Cloud Functions の必要性を再評価。
発見
Firebase Cloud Functions で実装していた sendMail の実体は単なる Slack Incoming Webhook への POST。
// Firebase Cloud Functions の実体
export const sendMail = httpsCallable(async (data) => {
return fetch('https://hooks.slack.com/services/...', {
method: 'POST',
body: JSON.stringify({ text: message })
});
});
問題点
- GCP インスタンス起動、スケーリング、ホスト、監視の複雑さ
- コールドスタート遅延
- 固定コスト(Active プロジェクト課金)
- 学習コスト・運用オーバーヘッド
解決策
エッジ関数(CF Pages Functions)で同じ処理を 1 行で実装。
// CF Pages Functions (functions/contact.ts)
export const onRequest: PagesFunction = async (context) => {
const { name, email, message } = await context.request.json();
await fetch('https://hooks.slack.com/services/...', {
method: 'POST',
body: JSON.stringify({ text: `${name}: ${message}` })
});
return new Response('OK');
};
学び
インフラの複雑さは必要性から逆算すべき。 単純な webhook 処理には GCP VM より CF Functions の方が適切。デプロイも自動。
移行メリット
- デプロイが簡単(
functions/に置くだけ) - コールドスタートなし
- 従量課金(Free 10万req/日)
- 監視・スケーリング不要
概要
Firebase Cloud Functions のシークレット(API キー、Webhook URL など)は Google Cloud Secret Manager で一元管理する。
登録方法
# シークレットを登録
firebase functions:secrets:set SECRET_NAME
# ユーザー入力を促される(パスト可能)
# または CI/CD で:
firebase functions:secrets:set SECRET_NAME --data "secret-value"
関数での使用
// functions/src/index.ts
import * as functions from 'firebase-functions/v2';
export const sendMail = functions.https.onCall(
{
secrets: ['SLACK_WEBHOOK_URL', 'SENDGRID_API_KEY']
},
async (request, context) => {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
const apiKey = process.env.SENDGRID_API_KEY;
// 処理...
}
);
取得方法
デプロイ後、シークレット値を確認する場合:
firebase functions:secrets:access SECRET_NAME
ベストプラクティス
- secrets 配列に列挙する → 必要な値のみロード、セキュリティ向上
- Git リポジトリにはコミットしない → Secret Manager で一元管理
- 環境別に別々のシークレット → dev / prod で切り分け
注意
Astro + CF Functions へ移行する場合は wrangler secrets に移行する。
背景
- Google reCAPTCHA は Google infrastructure 依存
- Cloudflare Pages への移行に伴い、Cloudflare 内部で完結する Turnstile に変更
差異比較
| 項目 | Google reCAPTCHA | Cloudflare Turnstile |
|---|---|---|
| チャレンジ | ロボット判定 | ロボット判定 |
| パフォーマンス | 速い | より高速(CF エッジで実行) |
| プライバシー | Google に送信 | CF 内部で完結 |
| 料金 | 無料 | 無料(CF Pages Free含む) |
| デプロイ | Google Console | wrangler.toml + env var |
| キャプチャUI | Google ブランド | CF ブランド(かつシンプル) |
実装
1. Cloudflare 側
# wrangler.toml
[[env.production.r2_buckets]]
name = "TURNSTILE_SECRET_KEY"
binding = "TURNSTILE_SECRET_KEY"
[env.production.vars]
TURNSTILE_SITE_KEY = "1x..."
2. フロント(Astro コンポーネント)
---
import { TURNSTILE_SITE_KEY } from 'astro:env/client';
---
<form id="contact-form">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<!-- Turnstile ウィジェット -->
<div class="cf-turnstile" data-sitekey={TURNSTILE_SITE_KEY}></div>
<button type="submit">送信</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
document.getElementById('contact-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const token = window.turnstile.getResponse();
if (!token) {
alert('Please complete the verification');
return;
}
const formData = new FormData(e.target);
formData.append('cf-turnstile-response', token);
await fetch('/api/contact', {
method: 'POST',
body: new URLSearchParams(formData)
});
});
</script>
3. バック(Pages Functions)
// functions/contact.ts
export const onRequest: PagesFunction = async (context) => {
const formData = await context.request.formData();
const token = formData.get('cf-turnstile-response');
// Turnstile トークン検証
const verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const verifyResponse = await fetch(verifyUrl, {
method: 'POST',
body: JSON.stringify({
secret: context.env.TURNSTILE_SECRET_KEY,
response: token
})
});
const data = await verifyResponse.json();
if (!data.success) {
return new Response('Verification failed', { status: 400 });
}
// フォーム処理続行...
};
メリット
- プライバシー: Google と通信なし
- 速度: Cloudflare エッジで即座に検証
- 一体性: Pages + Turnstile で CF 内完結
- UI/UX: Turnstile のキャプチャがシンプル
移行時の注意
- Turnstile は reCAPTCHA v3(スコアベース)ではなく v2 相当(チャレンジベース)
- 既存のスコアロジックがあれば別途実装が必要
従来アーキテクチャ(GCP VM)
User Request
↓
GCP Cloud Run / App Engine VM ← コールドスタート / スケーリング遅延
↓
HTML 生成 / SSR
↓
レスポンス
問題
- コールドスタート: 初回実行時 500ms 以上
- スケーリング: 同時アクセス増加時に自動スケール待ち
- 往復レイテンシ: 物理的な距離(地理的に離れたデータセンター)
- 固定コスト: インスタンス維持費
新アーキテクチャ(Astro → CF Pages CDN)
User Request
↓
CDN エッジ(地理的に近い)← 99.99% はキャッシュヒット
↓
静的 HTML / JS / CSS 返却(ミリ秒単位)
↓
レスポンス
メリット
- ゼロレイテンシ: キャッシュは世界 200+ のエッジロケーション
- スケーリング不要: CDN が自動的に分散
- コールドスタート無し: ファイルサーバーなので即座に返却
- 従量課金: 無制限の静的配信(Free)
実測値の例
| 指標 | GCP Cloud Run(SSD ストレージ) | CF Pages CDN |
|---|---|---|
| TTFB(東京から) | 150-300ms | 20-50ms |
| キャッシュヒット率 | N/A(毎回生成) | 99.9% |
| スケール対応 | 数秒待ち | 即座 |
| 月額コスト | $10-50 | $0(Free) |
Astro + CF Pages が強い理由
ビルド時生成
# yarn build
astro build
↓
dist/ に静的 HTML/CSS/JS を生成
↓
Pages が自動的に CF CDN に配置
Edge Functions は必要な時だけ
// Pages Functions(エッジで実行、スピン不要)
export const onRequest: PagesFunction = async (ctx) => {
return fetch('https://api.example.com/data');
};
動的処理はエッジ関数で即座に実行。サーバーVM起動なし。
チューニングのポイント
- キャッシュキー: 静的アセットに
Cache-Control: max-age=31536000(1年) - Stale-While-Revalidate: ISR 相当(バックグラウンド再生成)
- 圧縮: CF が自動的に Brotli 圧縮
// wrangler.toml でのキャッシュ設定
[env.production]
routes = [
{ pattern = "/static/*", cache_control = "public, max-age=31536000" },
{ pattern = "/*", cache_control = "public, max-age=3600, stale-while-revalidate=86400" }
]
移行判断基準
- 静的コンテンツ 80% 以上 → Astro + CDN(この場合)
- ユーザーごとの動的生成 → SSR 維持(Next.js)
- リアルタイム更新 → SPA + API(React)
結論: 静的配信できるなら CDN エッジが最高パフォーマンス。サーバーレス関数は webhook/API にのみ使う。
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 本番環境と同じ条件でテストする。