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

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

公開日 2026-10-25 · https://ikiform.com/ja/guides/contact-form-static-site/cloudflare-pages

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
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 のお問い合わせフォーム

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`:

```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`:

```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](https://ikiform.com/ja/docs/sdk.md) の `challenge()` が必要です。[人間確認](https://ikiform.com/ja/docs/spam.md#human-check)を参照してください。

## 自分のキーか Ikiform の確認か

|   | Ikiform の確認 | 自分の Turnstile キー |
| --- | --- | --- |
| 管理するキー | なし | Cloudflare のダッシュボードのサイトキーとシークレット |
| HTML フォームの変更 | なし。確認は ikiform.com のページで実行 | ウィジェットとスクリプトを追加 |
| `fetch` 版 | SDK の `challenge()` を呼ぶ | ウィジェットの `cf-turnstile-response` を送る |
| Content-Security-Policy | Ikiform 用の `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`:

```text
/*
  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` を追加してください。登録済みドメインのサブドメインは許可されます。 |

## よくある質問

### Cloudflare Pages のお問い合わせフォームに Pages Functions は必要ですか？

いいえ。Function は送信を受け付ける方法の 1 つですが、その場合はバリデーション、メール送信、スパムフィルタリングを自分で書くことになります。フォームバックエンドに送信すれば、プロジェクトは完全に静的なままです。

### プレビューデプロイには個別の許可ドメインが必要ですか？

いいえ。Pages のプレビュー URL は your-project.pages.dev のサブドメインなので、そのホストを許可すればすべてカバーされます。

### 既存の Turnstile ウィジェットを Ikiform で使えますか？

はい。フォームのスパムチェックを自分の Turnstile キーに設定し、サイトキーとシークレットを貼り付けます。Ikiform は各トークンを検証し、そのホスト名を許可ドメインと照合します。

### Cloudflare Pages でフォームが 405 になるのはなぜですか？

自分の Pages の URL に送信したためです。そこは GET リクエストにしか応答しない静的アセットを配信しています。フォームの action をフォームバックエンドに向けるか、そのパスに Pages Function を追加してください。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払いの前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。

## その他のガイド

- [静的サイトのお問い合わせフォーム](https://ikiform.com/ja/guides/contact-form-static-site.md): サーバーなしで静的サイトに動くお問い合わせフォームを追加します。POST の送り先、スパム、メールでの返信、各ホストとフレームワーク向けのコードまで。
- [Vercel](https://ikiform.com/ja/guides/contact-form-static-site/vercel.md): Vercel の静的ページはフォームの POST に 405 Method Not Allowed を返します。その理由と、代わりにバックエンドへフォームを送る方法を説明します。
- [Netlify](https://ikiform.com/ja/guides/contact-form-static-site/netlify.md): Netlify Forms を使わずに、Netlify のサイトでお問い合わせフォームを運用します。ビルド時の検出なし、React からの JSON 送信、デプロイプレビュー用の別フォーム。
- [GitHub Pages](https://ikiform.com/ja/guides/contact-form-static-site/github-pages.md): GitHub Pages ではサーバーコードが動かず、フォームの POST は 405 Not Allowed になります。フォームバックエンドに送信し、プロジェクトサイトのリダイレクトを正しく設定しましょう。
- [Amazon S3](https://ikiform.com/ja/guides/contact-form-static-site/aws-s3.md): S3 のウェブサイトエンドポイントは平文 HTTP の GET と HEAD しか受け付けません。お問い合わせフォームはフォームバックエンドに送信し、リダイレクトは CloudFront で正しく扱います。
- [ポートフォリオサイト](https://ikiform.com/ja/guides/contact-form-static-site/portfolio-website.md): 初回の打ち合わせで聞くことを尋ねるポートフォリオのお問い合わせフォーム。案件、予算、時期、リンクを、サーバーなしで受け取り、受信トレイから返信できます。
