自分のサイトにお問い合わせフォームを付けたいけれど、そのためにサイト全体を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月時点の情報で、このあたりは仕様がよく変わるので、最新は各サービスのダッシュボードと公式ドキュメントで確認してください。

要点

構成と費用

最終的な構成はこうなった。

お問い合わせフォームの構成。ブラウザから POST /api/contact に送り、Cloudflare Pages Functions が入力と Turnstile を検証してから、Resend で自分宛の通知と自動返信の2通を送る

この構成でいちばん助かったのは、サイト本体に手を入れなくて済むところだった。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には新しいレコードを足すだけで済み、今のメールの設定には一切触れずに済む。

送信ドメインの分け方。今のメールがある example.com の MX と SPF には触らず、send.example.com に Resend 用の TXT と CNAME を足す

追加する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() を呼んで、次の送信に備えるようにした。

安全側に倒した設計

ほかに、いくつか判断したことをまとめておく。

この中の「未設定なら閉じる」という判断は、後でつまずく原因にもなったので、失敗談の方で書く。

ローカルで「弾かれること」まで確かめる

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でも回せる。

種類

キー

結果

サイトキー

1x00000000000000000000AA

常に成功

シークレット

1x0000000000000000000000000000000AA

常に成功

シークレット

2x0000000000000000000000000000000AA

常に失敗

失敗する方のシークレットを使えば、Turnstileの検証に落ちたときに本当に送信されないかを実際に確かめられる。bot対策が効いているかどうかを推測で済ませたくなかったので、ここは全部の分岐を叩いて確認した。

入力

結果

GET

405

壊れたJSON/未入力/メール形式の誤り/同意なし/長すぎる

400(それぞれ日本語のメッセージ)

ハニーポットが埋まっている

200 {ok:true}(黙って破棄)

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つを決めた。

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行だった。

Resend のドメイン認証で指示されたレコードの違い。公式ガイドは MX と TXT、実際のダッシュボードは DKIM の TXT と CNAME が2つ

ガイドの方が古くて、ダッシュボードの方が今の仕様だった。ここで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でデプロイし直したあと、いきなりメールを送るのではなく、次の順に確かめた。

  1. /contact/ に「準備中」が無く、data-sitekey がテストキーではなく本番のサイトキーになっている
  2. GET /api/contact が405、空のPOSTが400を返す
  3. 偽のトークンでPOSTすると403を返す

3つめの403には意味があって、環境変数が欠けていれば503になる作りなので、403が返るということは、変数が読めていて、本物のシークレットでTurnstileの検証が走ったという証拠になる。メールを1通も送らずに、ここまでは確かめられる。

最後に、プライベートウィンドウから実際に1通送ってみて、自分宛の通知と自動返信の2通が届いたのを確認した。

振り返って

作っている途中で一番時間を使ったのは、コードを書くことより、書いた物が本当に動いているかを確かめることだったと思う。MailChannelsの古い手順も、Resendのガイドも、書かれていることがもう今の状態と違っていたし、フォームそのものも、実装は終わっているのに2か月、誰も送れない状態だった。

どれも、ドキュメントや自分のコードを読んでいるだけでは気づけず、ダッシュボードの表示を見たり、ローカルや本番でURLを叩いたりして初めて分かったことだった。テストキーで弾かれることまで確かめて、本番では403が返るのを見てから送信する、という順番にしておいてよかったなと改めて感じている。

これからフォームを付ける人は、実装が終わったところで一度本番のURLを叩いて、「準備中」になっていないかを確かめてみてください。