[ Blog ]
#Astro#Cloudflare Pages#お問い合わせフォーム#Turnstile#静的サイト

静的サイトのお問い合わせフォームを、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をやめても、フォームはちゃんと作れます。仕組みを理解して組んでおけば、公開後にフォームが動かないと慌てることも減るはずです。


納品して終わりではありません。サイト公開後も相談できる制作者をお探しの方は、まずはお話をお聞かせください。

[ Contact ]

まずは、ご状況を
お聞かせください。

サイトの新規制作・リニューアル・
運用改善など、
課題が明確でなくてもご相談いただけます。
2営業日以内にご返信いたします。

お問い合わせページへ