静的サイトのお問い合わせフォームを、Cloudflare Pages Functionsで作る
WordPressをやめて静的サイトにすると、最初に詰まるのがフォームです。PHPが動かないので、Contact Form 7のようなプラグインが使えません。
このサイトで実際に動いている構成をそのまま書きます。「Astroで作ったけどフォームをどうしよう」という制作者向けの実装の話です。前提として、なぜWordPressをやめたかは別の記事に書きました。
なぜ静的サイトのフォームは一手間かかるのか
WordPressなら、フォームプラグインを入れれば送信・メール通知・スパム対策まで面倒を見てくれます。裏でPHPが動いて、サーバー側で処理してくれているからです。
ところがAstroなどで書き出した静的サイトには、そのサーバー側の処理がありません。HTMLとCSSとJavaScriptが置いてあるだけです。だから素のままだと、置き場所が無いものが3つ出てきます。
- フォームの内容をどこに送るのか
- スパム対策をどこでやるのか
- 送信に必要な秘密の鍵をどこに置くのか(HTMLに書いたら丸見え)
とくに3つ目が厄介です。これらの置き場所をどう用意するかが、静的サイトのフォームの肝になります。
全体の構成
私がよく使っているのは、こういう流れです。
ブラウザのフォーム(Astro / 静的HTML)
│ ① fetchで送信
▼
Cloudflare Pages Functions(/api/contact) ← ここが「サーバー側」
│ ② Turnstileでスパム判定
│ ③ Googleフォームへ転送
▼
Googleフォーム → スプレッドシートに蓄積
鍵になるのがCloudflare Pages Functionsです。Cloudflare Pagesは静的ホスティングですが、functions/に置いたファイルだけはサーバー側で動きます(サーバーレス関数)。ここに、本来PHPでやっていた処理を寄せる、というのが今回の考え方です。
この構成の良いところは3つあります。まず、Cloudflare Pages・Turnstile・Googleフォームと、無料の範囲で完結すること。次に、スパム対策の秘密鍵をブラウザに晒さず、関数の環境変数に隠せること。そして、データの受け皿がGoogleフォームとスプレッドシートなので、エンジニアでなくても回答の一覧を見られることです。
ここから順番に作っていきましょう。
1. Googleフォームを用意する
最初は、受け皿になるGoogleフォームを普通に作るところからです。質問項目(お名前・メール・本文など)を用意し、回答をスプレッドシートに保存する設定にしておきます。
次は、各項目のフィールドID(entry.xxxxxxxxx)を調べる番です。フォームのプレビュー画面でブラウザの開発者ツールを開き、各入力欄のname属性を見ると分かります。entry.157333572のような値です。あとで、自分のサイト側のフォームでnameにこの値をそのまま使います。
メール自動返信をしたい場合は、Googleフォームの「回答」設定や、スプレッドシートに紐づけたGAS(Google Apps Script)で対応できます。今回の本筋からは外れるので割愛します。
2. Cloudflare Turnstileのキーを取得する
Turnstileは、Cloudflareが無料で提供しているスパム対策です。reCAPTCHAのようなものだと思ってください。管理画面でサイトを登録すると、2つのキーが発行されます。
- サイトキー(公開してOK。HTMLに書く)
- シークレットキー(絶対に公開しない。サーバー側だけで使う)
このシークレットキーの置き場所が静的サイトだと悩みどころなのですが、今回はCloudflare Pages Functionsの環境変数に入れて隠します。
3. Astro側のフォームを書く
フォーム本体です。nameにはさっきのGoogleフォームのフィールドIDを使います。あわせて、スパム避けのハニーポット(人間には見えない入力欄。botだけが入力してしまう)と、Turnstileのウィジェットを置きます。
<form id="contact-form" action="/api/contact" method="POST">
<input type="text" name="entry.1846225684" placeholder="お名前" required />
<input type="email" name="entry.1270779702" placeholder="メールアドレス" required />
<textarea name="entry.1985847602" rows="6" required></textarea>
<!-- ハニーポット(CSSで隠す。botよけ) -->
<div aria-hidden="true" style="position:absolute; left:-9999px;">
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<!-- Turnstile(サイトキーはここに書いてOK) -->
<div class="cf-turnstile" data-sitekey="0xYOUR_SITE_KEY"></div>
<button type="submit">送信する</button>
</form>
<!-- Turnstileの読み込み -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
送信は、ページ遷移せずにfetchで/api/contactに投げ、成功したら完了メッセージを出す形にします。
document.addEventListener('submit', function (e) {
var form = e.target;
if (form.id !== 'contact-form') return;
e.preventDefault();
// ハニーポットに値が入っていたらbotとみなして無視
if (form.querySelector('[name="website"]').value) return;
// Turnstileのトークンが無ければ送らせない
var token = form.querySelector('[name="cf-turnstile-response"]');
if (!token || !token.value) {
alert('認証を完了してください。');
return;
}
fetch('/api/contact', { method: 'POST', body: new FormData(form) })
.then(function (res) {
if (!res.ok) throw new Error(res.status);
// 送信完了メッセージを表示する処理
})
.catch(function () {
// エラー表示&Turnstileリセット
if (typeof turnstile !== 'undefined') turnstile.reset();
});
});
cf-turnstile-responseという隠しフィールドは、認証に成功するとTurnstileが自動で作るものです。サーバー側で検証するのは、この値です。
4. Cloudflare Pages Functions(サーバー側の処理)
ここが心臓部です。functions/api/contact.jsを作ると、/api/contactへのアクセスをサーバー側で処理できます。やることは2つ、Turnstileの検証とGoogleフォームへの転送です。
export async function onRequestPost(context) {
const { request, env } = context;
const body = await request.formData();
const token = body.get("cf-turnstile-response");
// --- ① Turnstileを検証する ---
const verify = 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,
remoteip: request.headers.get("CF-Connecting-IP"),
}),
},
);
const outcome = await verify.json();
if (!outcome.success) {
return new Response(JSON.stringify({ error: "verification-failed" }), { status: 403 });
}
// --- ② Googleフォームへ転送する ---
const googleFormURL =
"https://docs.google.com/forms/d/e/XXXXXXXXXXXX/formResponse"; // 自分のフォームのURL
const formData = new URLSearchParams();
formData.append("entry.1846225684", body.get("entry.1846225684") || "");
formData.append("entry.1270779702", body.get("entry.1270779702") || "");
formData.append("entry.1985847602", body.get("entry.1985847602") || "");
const gRes = await fetch(googleFormURL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0",
},
body: formData.toString(),
redirect: "follow",
});
if (!gRes.ok) {
return new Response(JSON.stringify({ error: "forward-failed" }), { status: 502 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
やっていることは単純です。フォームから受け取ったTurnstileトークンをCloudflareのsiteverifyに投げて、本物の人間かどうかを確認する。通ったら、GoogleフォームのformResponseエンドポイントに項目を転送する。それだけです。
大事なのは、Turnstileのシークレットキーも、Googleフォームの送信先URLも、すべてこのサーバー側の関数の中だけで扱っている点です。ブラウザ側のHTMLには出てきません。だからスパムbotに直接叩かれにくい。
最後に、Cloudflare Pagesの管理画面で環境変数TURNSTILE_SECRET_KEYにシークレットキーを設定すれば完成です。
実際につまずいた点
動かしてみると、静的サイトならではのハマりどころがいくつかありました。
ページ遷移でTurnstileが表示されなくなる。 Astroのページ遷移(View Transitions / ClientRouter)を使っている場合です。遷移で戻ってきたときにTurnstileが自動描画されず、ウィジェットが空になることがあります。astro:page-loadのタイミングで、空ならturnstile.render()を明示的に呼ぶ必要がありました。
二重送信。 同じくページ遷移の仕組みで送信スクリプトが多重に登録され、1回の送信が二重に飛ぶことがありました。documentへのイベント委譲、初回だけ登録するガード、送信中フラグの3つで防いでいます。
Turnstile未完了のまま送信。 送信前に必ずトークンの有無をチェックし、無ければその場で止めます。
Googleフォームへの転送。 Content-Typeをapplication/x-www-form-urlencodedにし、redirect: "follow"を付けておくのが安定します。
ほかの作り方
今回はCloudflare Pages Functions+Turnstile+Googleフォームで組みましたが、静的サイトのフォームはこれ一択ではありません。
- 外部フォームサービス(Formspree / Basin / Getformなど):フォームの
actionをサービスのURLに向けるだけで、送信・メール通知・スパム対策までやってくれます。とにかく早く設置したいときはこれが最速。無料枠は月あたりの送信数に制限があり、増えると有料になります - ホスティング内蔵のフォーム機能(Netlify Formsなど):Netlifyにデプロイしているなら、フォームに属性を1つ足すだけで送信を受け取れます。他社へ移すと使えなくなる点だけ注意
- サーバーレス関数+メール送信API(Resend / SendGridなど):今回のように関数を1枚挟み、Googleフォームではなくメール送信サービス経由で直接メールを送るやり方です。送信APIのキーは、今回と同じく環境変数に隠します
- Google Apps Script(GAS)を直接エンドポイントにする:GASで受信用のスクリプト(
doPost)を書き、そのURLへ送信する方法です。スプレッドシート保存や自動返信メールまで一つで組めるのが強み
スパム対策も、Turnstileの代わりにreCAPTCHAやhCaptchaを使えます。仕組みはだいたい同じで、サーバー側で検証するという考え方は共通です。
正直、とにかく早く済ませたいなら外部サービスが一番ラクです。それでも私がこの自前構成を選ぶのは、無料で完結することと、データの受け皿を自分やお客さんのGoogleアカウント側に持てることが大きい。外部サービスは月額やプラン制限、乗り換えのしにくさがついて回るので、長く運用する前提だと、この形が結局ラクだと感じています。
まとめ
静的サイトのフォームで大事なのは、サーバー側の処理の置き場所をどう用意するかです。Cloudflare Pages Functionsに処理を寄せれば、Turnstileのシークレットや送信先を隠したままフォームを作れます。データの受け皿をGoogleフォームとスプレッドシートにしておくと、エンジニアでなくても回答を確認できる。そして、Turnstileの再描画や二重送信といったページ遷移まわりが、静的サイトならではのハマりどころです。
WordPressをやめても、フォームはちゃんと作れます。仕組みを理解して組んでおけば、公開後にフォームが動かないと慌てることも減るはずです。
納品して終わりではありません。サイト公開後も相談できる制作者をお探しの方は、まずはお話をお聞かせください。