静的サイトのお問い合わせフォーム

静的サイトのお問い合わせフォーム2 分で読めます

Cloudflare Pages のサイトにお問い合わせフォームを追加する

Function を書かずに Cloudflare Pages にお問い合わせフォームを追加します。フォームバックエンドに送信し、_headers でヘッダーを設定し、自分の Turnstile キーを使います。

Preet SutharIkiform 創業者

Cloudflare Pages の静的アセットは POST に 405 を返すので、Pages Function を書くか、フォームをフォームバックエンドに送信するかのどちらかです。Ikiform なら Function は不要です。フォームの action をエンドポイントに設定し、Allowed domains に your-project.pages.dev を追加すれば、すべてのプレビュー URL もカバーされます。_headers の Content-Security-Policy には Ikiform を含めてください。すでに Cloudflare を使っているなら、自分の Turnstile キーをフォームに設定することもできます。

Cloudflare Pages ならではの違い

Pages はビルド結果を Cloudflare のネットワークから配信します。静的アセットは GET に応えますが、POST にはボディなしの 405 を返します:

POST への応答
HTTP/2 405
server: cloudflare
content-length: 0

Pages では、functions/ フォルダーの Pages Functions でコードを実行できます。functions/contact.ts で送信を受け付けることもできますが、そうなるとバックエンドを書くことになります。解析、バリデーション、メールプロバイダー、スパム除外、保存です。Cloudflare 自身のドキュメントも、新しいプロジェクトは Pages ではなく Workers で始めるよう勧めています。フォームバックエンドに送信すればどちらの問題も避けられ、後で Workers の静的アセットに移っても同じフォームがそのまま動きます。

Pages の特徴フォームへの影響
プレビューの URL は abc123.your-project.pages.dev のようなサブドメイン許可ドメイン your-project.pages.dev 1 つで、本番とすべてのプレビューをカバーできる。Vercel や Netlify のプレビューはそれぞれ個別の登録が必要
ヘッダーはビルド結果の _headers ファイルで指定するルールは最大 100 個、1 行 2,000 文字まで。Pages Functions からの応答には適用されない
Turnstile は Cloudflare の製品鍵なしで Ikiform のマネージドな確認を使うことも、Cloudflare のダッシュボードで作った自分のウィジェットを使うこともできます
自分の Turnstile キーを使う Cloudflare Pages のお問い合わせフォームpages.devのページ_headers付きで配信Turnstileウィジェット自分のサイトキーIkiformシークレットで検証保存とメール通知403captcha_failedウィジェットをリセットトークンPOST + トークン不正または使用済みのトークン自分の Turnstile キーを使う Cloudflare Pages のお問い合わせフォームpages.dev のページ_headers 付きで配信Turnstile ウィジェット自分のサイトキーIkiformシークレットで検証保存とメール通知403captcha_failedウィジェットをリセットトークンPOST + トークン不正または使用済みのトークン
  1. Pages がお問い合わせページを配信し、_headers ファイルの Content-Security-Policy を付けます。
  2. Turnstile ウィジェットがサイトキーで動作し、フォームにトークンを入れます。
  3. フォームはフィールドとトークンを Ikiform に送信します。
  4. Ikiform はシークレットキーでトークンを検証し、トークンのホスト名を許可ドメインと照合します。
  5. 有効な送信は保存され、メールで通知されます。トークンがない、期限切れ、再利用の場合は 403 captcha_failed になり、ページがウィジェットをリセットします。
代わりに Ikiform のマネージドな確認を使う場合、ページにウィジェットは置かず、HTML フォームも一切変更不要です。

フォーム

この版では自分の Turnstile ウィジェットを使います。すでに Cloudflare アカウントで Turnstile を管理しているなら理にかなっています。フォームの設定で Spam check を Your own Turnstile keys にし、サイトキーとシークレットを貼り付けます。ウィジェットはフォームに cf-turnstile-response フィールドを追加し、team のセレクトで誰が返信すべきかがわかります:

public/contact.html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="team">Who should read this?</label>
  <select id="team" name="team">
    <option value="support">Support</option>
    <option value="sales">Sales</option>
    <option value="press">Press</option>
  </select>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAAxxxxxxxxxxxxxx"></div>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send</button>
  <p id="contact-status" role="status"></p>
</form>

<script src="/contact.js" defer></script>

スクリプトで訪問者をページにとどめます。Turnstile のトークンは 1 回しか使えないので、送信に失敗したら次の試行のためにウィジェットをリセットします:

public/contact.js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  status.textContent = "Sending";
  const response = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(form),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (result?.ok) {
    form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Sent. We'll reply by email." }));
    return;
  }

  window.turnstile?.reset();
  if (result?.error?.code === "captcha_failed") {
    status.textContent = "The human check expired. Complete it again, then send.";
    return;
  }
  const fields = result?.error?.fields ?? {};
  status.textContent = Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
});
  • Cloudflare の Turnstile ウィジェットの設定で、フォームを置くホスト名を登録します。Ikiform もトークンのホスト名をフォームの Allowed domains と照合します。
  • 有効なトークンのない送信は 403 captcha_failed で拒否されます。ウィジェットの処理が終わる前に送られたプレーンな HTML の送信も含みます。
  • 鍵を一切使いたくない場合は、Spam check を Ikiform にし、ウィジェットとそのスクリプトを削除すれば、HTML の送信は ikiform.com で確認を実行します。その場合、fetch 版には SDK の challenge() が必要です。人間確認を参照してください。

自分のキーか Ikiform の確認か

ItemIkiform の確認自分の Turnstile キー
管理するキーなしCloudflare のダッシュボードのサイトキーとシークレット
HTML フォームの変更なし。確認は ikiform.com のページで実行ウィジェットとスクリプトを追加
fetch 版SDK の challenge() を呼ぶウィジェットの cf-turnstile-response を送る
Content-Security-PolicyIkiform 用の form-action と connect-src だけscript-src と frame-src に challenges.cloudflare.com も追加
Turnstile の分析自分のアカウントにはないCloudflare のダッシュボードで、他のウィジェットと並んで見られる

Ikiform の確認のほうが設定は少なくて済みます。すでに Turnstile の分析を見ている場合や、ウィジェットの見た目や配置を自分で管理したい場合は、自分のキーが向いています。

ローカルでウィジェットをテストする

Cloudflare は、常に成功する、または常に失敗するテスト用のサイトキーとシークレットを公開しています。たとえば常に成功するペアは 1x00000000000000000000AA と 1x0000000000000000000000000000000AA です。本番用のシークレットはテスト用キーで作られたトークンを拒否するので、1 つのフォームで混ぜないでください。ローカル作業用に 2 つ目の Ikiform フォームを作ってテスト用のキーペアを設定し、data-sitekey とフォーム ID をセットで切り替えます。そのフォームの Allowed domains は空のままにしてください。Ikiform がトークンのホスト名を確認するのはリストが設定されているときだけで、テスト用トークンがどのホスト名を報告するかは Cloudflare が文書化していません。

_headers ファイル

_headers を public/ などの静的アセットのディレクトリに置き、ビルド出力に含まれるようにします。Turnstile には https://challenges.cloudflare.com 用の script-src と frame-src が、Ikiform には form-action と connect-src が必要です:

public/_headers
/*
  Content-Security-Policy: default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com

後で同じプロジェクトに Pages Function を追加した場合、そのレスポンスにはこれらのヘッダーが付きません。Function のコードで設定してください。

Workers に移行する場合

サイトを Workers の静的アセットに移しても、変わるのはデプロイ方法で、フォームの動作は変わりません。フォームは引き続き ikiform.com に送信するので、Cloudflare 側で処理するものはありません。移行後は 2 点を確認してください。新しい workers.dev のホストやルートが Allowed domains にあること、そしてヘッダーが引き続きページに届いていることです。Workers にはヘッダーを設定する独自の方法があります。

your-project.pages.dev とカスタムドメインを Allowed domains に追加します。pages.dev だけを追加してはいけません。すべての Pages プロジェクトが一致してしまいます。

動作を確認する

  1. ブランチをプッシュし、プレビュー URL を開きます。送信して、メッセージが届き、スパム扱いになっていないことを確認します。プレビューはプロジェクトのホストのサブドメインです。
  2. 本番でもう一度送信し、戻るボタンを押して、ウィジェットを完了せずに同じフォームを再送信します。送信データが保存されるのではなく、リセットのメッセージが出るはずです。
  3. ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
  4. 通知の受信者それぞれの受信トレイを確認します。各自にフィールド入りのメールが届き、フォームに email フィールドがあれば Reply の宛先は送信者になります。
  5. 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。

ハマりどころ

問題対処
空のボディで 405フォームが Pages の URL に送信されています。デプロイされた action と、/contact を書き換える Function やリダイレクトルールがないことを確認してください。
Turnstile ウィジェットが表示されないContent-Security-Policy が challenges.cloudflare.com をブロックしています。script-src と frame-src に追加し、_headers がビルド出力に含まれていることを確認してください。
毎回 403 captcha_failed になるサイトキーとシークレットが別々のウィジェットのものか、ホスト名がウィジェットに登録されていないか、テスト用のシークレットと本番のサイトキーを組み合わせています。
403 origin_not_allowed が返るか、すべての送信がスパムになるページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルのテスト用に localhost を追加してください。登録済みドメインのサブドメインは許可されます。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com