静的サイトのお問い合わせフォームの作り方(Cloudflare Pages Functions + Turnstile + Googleフォーム)
前に「WordPressをやめてAstro + Cloudflare Pagesに移行した話」という記事を書きました。そのなかで「静的サイトはPHPが使えないので、お問い合わせフォームは別の作りにする必要がある」と触れたのですが、今日はそのフォームの作り方を、実際に自分のサイトで動いている構成をもとに、具体的に書きます。
「Astroで作ったけどフォームだけどうしよう」「WordPressをやめたら Contact Form 7 が使えなくなった」という制作者の方向けの、実装の話です。
なぜ静的サイトのフォームは一手間かかるのか
WordPressなら、フォームプラグインを入れれば、送信・メール通知・スパム対策まで一通り面倒を見てくれます。裏でPHPが動いて、サーバー側で処理してくれているからです。
ところが Astro などで書き出した静的サイトには、その「サーバー側の処理」がありません。HTMLとCSSとJavaScriptが置いてあるだけ。だから素のままだと、
- フォームの内容をどこに送るのか
- スパム対策をどこでやるのか
- メール送信に必要な秘密の鍵をどこに置くのか(HTMLに書いたら丸見え)
といった置き場所がありません。ここをどう埋めるか、が静的サイトのフォームの肝になります。
全体の構成
私が使っているのは、こういう流れです。
ブラウザのフォーム(Astro / 静的HTML)
│ ① fetch で送信
▼
Cloudflare Pages Functions(/api/contact) ← ここが「サーバー側」
│ ② Turnstile でスパム判定
│ ③ Googleフォームへ転送
▼
Googleフォーム → スプレッドシートに蓄積
ポイントは、Cloudflare Pages Functions です。Cloudflare Pages は静的ホスティングですが、functions/ に置いたファイルだけはサーバー側で動く(サーバーレス関数)という仕組みがあります。ここに「本来PHPでやっていた処理」を寄せる、というのが今回の考え方です。
この構成の良いところ:
- 無料の範囲で完結する(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へのイベント委譲+「初回だけ登録するガード」+「送信中フラグ」で防いでいます。 - 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フォームではなくメール送信サービス経由で直接メールを送るやり方です。Googleフォームを介さずメールで完結させたいときに。送信APIのキーは、今回と同じく環境変数に隠します。
- Google Apps Script(GAS)を直接エンドポイントにする
GASで受信用のスクリプト(
doPost)を書き、そのURLへ送信する方法です。スプレッドシート保存や自動返信メールまで一つで組めるのが強み。Cloudflareの関数を使わずに完結させたいときの定番です。
スパム対策も、Turnstile の代わりに reCAPTCHA / hCaptcha を使う手があります。仕組みはだいたい同じで、「サーバー側で検証する」という考え方は共通です。
正直、とにかく早く済ませたいなら外部サービスが一番ラクです。それでも私が今回の自前構成を選ぶのは、無料で完結することと、データの受け皿を自分(やお客さん)のGoogleアカウント側に持てることが大きいからです。外部サービスは月額やプラン制限、乗り換えのしにくさがついて回るので、長く運用する前提だと、この形が結局ラクだと感じています。ここは、サイトの規模と運用方針しだいで選び分けるところだと思います。
まとめ
- 静的サイトのフォームは、「サーバー側の処理」の置き場所をどう用意するかが肝
- Cloudflare Pages Functions に処理を寄せれば、Turnstileのシークレットや送信先を隠したままフォームが作れる
- データの受け皿は Googleフォーム+スプレッドシートにすると、非エンジニアでも回答を確認できる
- ページ遷移まわり(Turnstileの再描画・二重送信)は、静的サイトならではのハマりどころ
WordPressをやめても、フォームはちゃんと作れます。そして、仕組みを理解して組んでおくと、公開後に「フォームが動かない」と慌てることも減ります。
納品して終わりではありません。サイト公開後も相談できる制作者をお探しの方は、まずはお話をお聞かせください。