自分のサイトにお問い合わせフォームを付けたいけれど、そのためにサイト全体をSSRにしたり、外部のフォームサービスに頼ったりするのは少し気が進まない。そう感じている人は多いと思う。
自分のサイトもAstroの output:'static' で作っていて、しばらくお問い合わせフォームが無いままだった。仕事の入口が塞がっているのと同じ状態なので、SSGformのような外部サービスを使う手もあったけれど、今回はサイト全体を静的なまま保って、フォームだけを自作することにした。
この記事では、Cloudflare Pages FunctionsとTurnstile、Resendを組み合わせて0円でフォームを作った手順を、実際につまずいた順に書いておく。検索で出てくる古い手順がもう使えないこと、Resendのドメイン認証がガイドと違う形式だったこと、既存のメールを壊さないための送信ドメインの決め方、Turnstileの公式テストキーで「弾かれること」まで確かめた方法あたりが分かると思う。
検証環境: Astro(output:'static')/Cloudflare Pages Functions/wrangler 4/Turnstile/Resend 無料枠。2026年8月〜10月時点の情報で、このあたりは仕様がよく変わるので、最新は各サービスのダッシュボードと公式ドキュメントで確認してください。
要点
- サイトは
output:'static'のままでよい。リポジトリ直下のfunctions/だけをCloudflareがWorkersとして拾うので、SSR化もアダプタの導入もいらない - Cloudflare Workersから無料でメールを送れたMailChannelsの無料枠は2024年8月に終わっている。今はResendなどを使う
- Resendに登録する送信ドメインはサブドメインにする。apexに既存のSPFがあると、上書きして今のメールを壊してしまう
- Resendのドメイン認証は、ダッシュボードの指示がCNAME形式に変わっていた。ガイドと食い違ったらダッシュボードの方を正とする
wrangler pages devとTurnstileの公式テストキーを使えば、ローカルで「弾かれること」まで確かめられる
構成と費用
最終的な構成はこうなった。

この構成でいちばん助かったのは、サイト本体に手を入れなくて済むところだった。functions/ はAstroのビルド対象の外にあるので、astro build には一切触らず、フォームの送信先だけをCloudflareの側で動かせる。
費用はどれも無料の範囲に収まっている。
サービス | 役割 | 費用 |
|---|---|---|
Cloudflare Pages Functions | 送信を受け取って検証・送信する | 無料 |
Turnstile | bot対策。画像を選ばせるような操作が要らない | 無料 |
Resend | メール送信。月3,000通/日100通まで | 無料 |
個人サイトの問い合わせなら、Resendの無料枠で十分だと思う。
メール送信の手段を選ぶ
最初に「Cloudflare Workers 無料 メール送信」のような言葉で調べると、MailChannelsを使う手順がたくさん出てくる。ただ、MailChannelsの無料枠は2024年8月に終わっていて、その手順はもう使えない。調べた結果の中に、タイトルに「2024年8月以降利用不可」と書き足された記事があったので、古い手順を試す前に気づけた。
今の実質的な選択肢はResendか、SendGridなどの送信サービスになる。検索で上の方に出てくる記事ほど古いことがあるので、記事の日付は確かめてから手順を踏んだ方がいいと思う。自分はResendを選んだ。
送信ドメインはサブドメインにする
Resendで自分のドメインから送るには、ドメイン認証のためのDNSレコードを追加する必要がある。ここで気をつけたいのが、すでにレンタルサーバーなどで独自ドメインのメールを使っている場合だ。
自分のドメインのapexには、レンタルサーバーのSPFレコードがもともと入っていた。
v=spf1 a:(レンタルサーバーのホスト名) mx ~allこの状態でapex(example.com)をそのままResendに登録すると、SPFを上書きして既存のメールが壊れてしまう。そこで、Resendには send.example.com のようなサブドメインを登録することにした。こうすればDNSには新しいレコードを足すだけで済み、今のメールの設定には一切触れずに済む。

追加するDNSレコードは、CloudflareのProxyを必ず「DNS only」にしておく。
フォームとFunctionsを実装する
onRequest と onRequestPost は両方 export できる
Pages Functionsでは、onRequest と onRequestPost を両方exportしてもよく、その場合はメソッド別のハンドラが優先される。なので onRequestPost に本体の処理を書いて、onRequest ではそれ以外のメソッドに405を返す、という形にした。
CSPにTurnstile用の許可を3か所足す
サイトでContent-Security-Policyを設定しているなら、Turnstileのドメインを3か所に足す必要がある。
script-src ... https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src ... https://challenges.cloudflare.com;忘れやすいのは frame-src で、default-src 'self' だけだとTurnstileのiframeが読み込めず、ウィジェットがエラーも出さずに表示されないままになる。
Turnstileのトークンは送信のたびにリセットする
Turnstileのトークンは使い切りなので、fetchで送信してページが遷移しない作りだと、2回目の送信で必ず失敗する。送信が成功しても失敗しても finally の中で turnstile.reset() を呼んで、次の送信に備えるようにした。
安全側に倒した設計
ほかに、いくつか判断したことをまとめておく。
- Turnstileのサイトキーが未設定なら、フォーム自体を出さずに「準備中」と表示する。環境変数を入れ忘れたまま、bot対策の無い送信口を公開してしまうのを防ぐため
- ハニーポットの欄は
display:noneではなくposition:absolute; left:-9999pxで隠す。display:noneを無視するbotがいるため。埋まっていたらエラーは返さず、成功したように見せて黙って捨てる(botに学習させないため) - 自動返信の送信に失敗しても、エラーにはしない。本体のメールは届いているのに「失敗しました」と出す方が困ると思った
- 環境変数が足りないときは500ではなく503を返す。「壊れている」と「まだ繋がっていない」を区別できるようにするため
この中の「未設定なら閉じる」という判断は、後でつまずく原因にもなったので、失敗談の方で書く。
ローカルで「弾かれること」まで確かめる
astro dev ではPages Functionsは動かない。npm run build でビルドしてから、wranglerで配信すると本番と同じ形で動かせる。
npx wrangler@4 pages dev dist --port 8788 \
--binding TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA \
--binding RESEND_API_KEY=dummy \
--binding CONTACT_TO_EMAIL=test@example.com \
--binding CONTACT_FROM_EMAIL=noreply@send.example.com.env も自動で読み込まれて、--binding で渡したものと一緒に、起動時にバインディングの一覧が表示される。
Turnstileには、Cloudflare公式のテスト用キーが用意されている。実際のアカウントが無くても使えて、CIでも回せる。
種類 | キー | 結果 |
|---|---|---|
サイトキー |
| 常に成功 |
シークレット |
| 常に成功 |
シークレット |
| 常に失敗 |
失敗する方のシークレットを使えば、Turnstileの検証に落ちたときに本当に送信されないかを実際に確かめられる。bot対策が効いているかどうかを推測で済ませたくなかったので、ここは全部の分岐を叩いて確認した。
入力 | 結果 |
|---|---|
GET | 405 |
壊れたJSON/未入力/メール形式の誤り/同意なし/長すぎる | 400(それぞれ日本語のメッセージ) |
ハニーポットが埋まっている | 200 |
Turnstileの失敗キー | 403(送信されない) |
正常 | Turnstileを通過してResendまで届く |
_headers もローカルで確かめられる
もうひとつ分かったのが、wrangler pages dev なら public/_headers も適用されること。以前は「_headers はローカルでは効かないので本番でしか確かめられない」と思っていたけれど、それは astro preview の場合の話だった。
curl -sI http://localhost:8788/contact/ | grep -i content-security-policyこれでCSPのヘッダーがちゃんと出てくるので、Turnstile用の許可を足した結果を、本番に公開する前にローカルで確かめられる。
つまずいた事
ここまで書くと順調に作れたように見えるけれど、実際は作り終わってから開通するまでの方が時間がかかった。
CSPには、それまでに3回引っかかっていた
このサイトでは、フォームを作る前に、CSPに必要なドメインを書き忘れて読み込みが止まることを3回やっていた。日本語フォント、記事の写真、Cloudflareのアクセス解析の3つで、どれも本番に公開してから気づいた。原因はいつも同じで、本番に公開するまでCSPの効き方を確かめられないと思い込んでいた事だった。
なので今回は、Turnstile用の許可を先に3か所足して、上に書いた wrangler pages dev でCSPを効かせた状態で確かめてから公開した。フォームでは同じことは起きなかった。今後もCSPを変えたら、pushする前にローカルで curl を叩いて確かめる。
実装してから開通するまで、2か月かかった
8月4日にフォームを実装した時点で、コードは正しく動いていて、ビルドも通り、見た目も完成していた。ただ、ResendとTurnstileのアカウントを作って本番の環境変数を登録する、という残りの作業を後回しにしていた。サイトキー未設定ならフォームを出さないという設計のとおり、その間お問い合わせページは「準備中」のまま。開通したのは10月10日で、約2か月、仕事の入口が閉じていたことになる。
同じことは、あとで実績の一部をベーシック認証で閲覧制限にしたときにも起きた。3日後に状態を確かめてみたら、閉じたページが401ではなく503を返していた。認証用のユーザー名とパスワードをCloudflare Pagesに登録していなかったので、公開側では「パスワードをお渡しした方のみご覧いただけます」と案内しているのに、パスワードを渡しても誰も開けない状態だった。
未設定なら閉じるという判断は安全側で間違っていないと思う。ただ、こういう作りの機能は、実装が終わることとサービスとして開通することが別になる。そこで次の2つを決めた。
- 実装をコミットするときに、環境変数の登録を残りのタスクとして必ず残す(手順書に書くだけで終わらせない)
- 完了したかどうかは、コードやビルドではなく、本番のURLを叩いた結果で判断する
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/protected/ # 401ならOK。503なら未開通
curl -s https://example.com/contact/ | grep -c 準備中 # 0ならOKあわせて、パスワード未設定のあいだに実績ページの「パスワードをお持ちの方は こちら」を押すと503になる問題も直した。静的なページはビルドの時点で秘密の値があるかどうかを知らないし、ビルド時にSecretが読めるかは公式に明記が無いので頼れない。そこで、そのパスを担当するPages Functionsのミドルウェアで配信のたびに判定して、HTMLRewriterでリンクを消すようにした。
if (!env.WORKS_USER || !env.WORKS_PASSWORD) {
return new HTMLRewriter().on('[data-private-link]', { element: (el) => el.remove() }).transform(res);
}これなら再ビルドしなくても、秘密の値を登録した時点からリンクが表示される。@cloudflare/workers-types を入れていない場合は、HTMLRewriter を使う分だけ declare class で宣言すると型エラーが消える。
Resendのドメイン認証が30分以上「Checking DNS」のままだった
10月になって、ようやくResendに送信用のサブドメインを追加した。Auto configure(Cloudflare連携)でDNSレコードを入れたのに、30分以上たっても「Checking DNS」から進まなかった。
最初はDNSの入れ方を疑った。Resendの公式のCloudflare向けガイドには、送信元のレコードは「MX+TXT(amazonses.com)」と書かれている。ところが実際にダッシュボードが指示していたのは、次の3行だった。

ガイドの方が古くて、ダッシュボードの方が今の仕様だった。ここでCNAMEを想定外のレコードだと思って消していたら、かなり遠回りになっていたと思う。ドキュメントとダッシュボードの表示が食い違ったときは、ダッシュボードを正としてください。
dig @1.1.1.1 と dig @8.8.8.8 で3行とも引けたので、DNSは正しく入っていて、Resendの再チェックを待っているだけだと判断した。DNS Recordsの右上、Auto configureの隣にある ▶ を押して再検証したら、数分でVerifiedになった。
本番で開通を確かめる
環境変数を5つ(Turnstileのサイトキーとシークレットキー、ResendのAPIキー、送信元と送信先のアドレス)登録して、Retry deploymentでデプロイし直したあと、いきなりメールを送るのではなく、次の順に確かめた。
/contact/に「準備中」が無く、data-sitekeyがテストキーではなく本番のサイトキーになっているGET /api/contactが405、空のPOSTが400を返す- 偽のトークンでPOSTすると403を返す
3つめの403には意味があって、環境変数が欠けていれば503になる作りなので、403が返るということは、変数が読めていて、本物のシークレットでTurnstileの検証が走ったという証拠になる。メールを1通も送らずに、ここまでは確かめられる。
最後に、プライベートウィンドウから実際に1通送ってみて、自分宛の通知と自動返信の2通が届いたのを確認した。
振り返って
作っている途中で一番時間を使ったのは、コードを書くことより、書いた物が本当に動いているかを確かめることだったと思う。MailChannelsの古い手順も、Resendのガイドも、書かれていることがもう今の状態と違っていたし、フォームそのものも、実装は終わっているのに2か月、誰も送れない状態だった。
どれも、ドキュメントや自分のコードを読んでいるだけでは気づけず、ダッシュボードの表示を見たり、ローカルや本番でURLを叩いたりして初めて分かったことだった。テストキーで弾かれることまで確かめて、本番では403が返るのを見てから送信する、という順番にしておいてよかったなと改めて感じている。
これからフォームを付ける人は、実装が終わったところで一度本番のURLを叩いて、「準備中」になっていないかを確かめてみてください。
