# Ein Kontaktformular in eine Cloudflare-Pages-Site einbauen

> Bau ein Kontaktformular in Cloudflare Pages ein, ohne eine Function zu schreiben: an ein Formular-Backend senden, Header in _headers setzen und eigene Turnstile-Schlüssel nutzen.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/contact-form-static-site/cloudflare-pages

Statische Assets auf Cloudflare Pages beantworten einen `POST` mit `405`, also schreibst du entweder eine Pages Function oder sendest das Formular an ein Formular-Backend. Mit Ikiform schreibst du keine Function: Setze die `action` des Formulars auf deinen Endpunkt, füge `your-project.pages.dev` zu **Allowed domains** hinzu, was auch jede Preview-URL abdeckt, und nimm Ikiform in die Content-Security-Policy in deiner `_headers` auf. Wenn du Cloudflare schon nutzt, kannst du deine eigenen Turnstile-Schlüssel im Formular hinterlegen.

## Was bei Cloudflare Pages anders ist

Pages liefert deinen Build-Output aus dem Netzwerk von Cloudflare aus. Statische Assets beantworten `GET`; ein `POST` an eines davon bekommt `405` ohne Body:

`Antwort auf einen POST`:

```http
HTTP/2 405
server: cloudflare
content-length: 0
```

Pages kann Code ausführen, über Pages Functions in einem Ordner `functions/`. Eine `functions/contact.ts` könnte den Post annehmen, aber dann schreibst du ein Backend: Parsing, Validierung, einen E-Mail-Anbieter, Spamfilter und Speicherung. Die Dokumentation von Cloudflare selbst rät außerdem, neue Projekte auf Workers statt auf Pages zu starten. Ein Post an ein Formular-Backend umgeht beides, und dasselbe Formular funktioniert unverändert, falls du später zu Workers Static Assets wechselst.

| Pages-Eigenschaft | Was das für das Formular bedeutet |
| --- | --- |
| Preview-URLs sind Subdomains, etwa `abc123.your-project.pages.dev` | Eine erlaubte Domain, `your-project.pages.dev`, deckt die Produktion und jede Preview ab. Previews bei Vercel und Netlify brauchen eigene Einträge |
| Header kommen aus einer `_headers`-Datei im Build-Output | Bis zu 100 Regeln, 2.000 Zeichen pro Zeile. Sie gelten nicht für Antworten von Pages Functions |
| Turnstile ist ein Cloudflare-Produkt | Du kannst die verwaltete Prüfung von Ikiform ohne Schlüssel nutzen oder dein eigenes Widget aus dem Cloudflare-Dashboard |

Diagramm: Ein Kontaktformular auf Cloudflare Pages mit eigenen Turnstile-Schlüsseln

1. Pages liefert die Kontaktseite aus, mit der Content-Security-Policy aus deiner _headers-Datei.
2. Das Turnstile-Widget läuft mit deinem Site Key und legt ein Token im Formular ab.
3. Das Formular sendet seine Felder und das Token an Ikiform.
4. Ikiform verifiziert das Token mit deinem Secret und prüft den Hostnamen des Tokens gegen deine erlaubten Domains.
5. Ein gültiger Post wird gespeichert und per E-Mail gemeldet. Ein fehlendes, abgelaufenes oder wiederverwendetes Token bekommt 403 captcha_failed, und die Seite setzt das Widget zurück.

Mit der verwalteten Prüfung von Ikiform gibt es stattdessen kein Widget auf deiner Seite; das HTML-Formular braucht gar keine Änderung.

## Das Formular

Diese Version nutzt dein eigenes Turnstile-Widget, was sinnvoll ist, wenn du Turnstile schon in deinem Cloudflare-Konto verwaltest. Setze in den Einstellungen des Formulars **Spam check** auf **Your own Turnstile keys** und füge Site Key und Secret ein. Das Widget fügt dem Formular ein Feld `cf-turnstile-response` hinzu, und das Select `team` sagt dir, wer antworten soll:

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

Das Skript hält Besucher auf der Seite. Ein Turnstile-Token ist nur einmal gültig, daher setzt es nach jedem fehlgeschlagenen Senden das Widget für den nächsten Versuch zurück:

`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.";
});
```

- Trag in den Einstellungen des Turnstile-Widgets in Cloudflare die Hostnamen ein, auf denen das Formular läuft. Ikiform prüft den Hostnamen des Tokens außerdem gegen **Allowed domains** des Formulars.
- Posts ohne gültiges Token werden mit `403 captcha_failed` abgelehnt. Das gilt auch für einen einfachen HTML-Post, der gesendet wird, bevor das Widget fertig ist.
- Lieber ganz ohne Schlüssel? Setze **Spam check** auf **Ikiform**, lösche das Widget und sein Skript, und der HTML-Post führt die Prüfung auf ikiform.com aus. Die `fetch`-Version braucht dann `challenge()` aus dem [SDK](https://ikiform.com/de/docs/sdk.md); siehe [die Menschlichkeitsprüfung](https://ikiform.com/de/docs/spam.md#human-check).

## Deine Schlüssel oder die Prüfung von Ikiform

|   | Prüfung von Ikiform | Eigene Turnstile-Schlüssel |
| --- | --- | --- |
| Zu verwaltende Schlüssel | Keine | Ein Site Key und ein Secret aus deinem Cloudflare-Dashboard |
| Änderung am HTML-Formular | Keine. Die Prüfung läuft auf einer Seite von ikiform.com | Widget und sein Skript hinzufügen |
| Version mit `fetch` | `challenge()` aus dem SDK aufrufen | `cf-turnstile-response` des Widgets mitsenden |
| Content-Security-Policy | Nur `form-action` und `connect-src` für Ikiform | Zusätzlich `challenges.cloudflare.com` in `script-src` und `frame-src` |
| Turnstile-Analytics | Nicht in deinem Konto | In deinem Cloudflare-Dashboard, neben deinen anderen Widgets |

Die Prüfung von Ikiform ist weniger Einrichtung. Eigene Schlüssel lohnen sich, wenn du die Turnstile-Analytics ohnehin im Blick hast oder Aussehen und Platzierung des Widgets selbst bestimmen willst.

### Das Widget lokal testen

Cloudflare veröffentlicht Test-Site-Keys und -Secrets, die immer bestehen oder immer scheitern, etwa das Paar `1x00000000000000000000AA` und `1x0000000000000000000000000000000AA`, das immer besteht. Produktions-Secrets lehnen Tokens ab, die mit einem Testschlüssel erzeugt wurden, also misch sie nicht in einem Formular. Leg ein zweites Ikiform-Formular für die lokale Arbeit an, gib ihm das Test-Schlüsselpaar und wechsle `data-sitekey` und Formular-ID gemeinsam. Lass **Allowed domains** dieses Formulars leer: Ikiform prüft den Hostnamen eines Tokens nur, wenn die Liste gesetzt ist, und Cloudflare dokumentiert nicht, welchen Hostnamen ein Test-Token meldet.

## Die _headers-Datei

Leg `_headers` in dein Verzeichnis für statische Assets, etwa `public/`, damit sie im Build-Output landet. Turnstile braucht `script-src` und `frame-src` für `https://challenges.cloudflare.com`, und Ikiform braucht `form-action` und `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
```

Wenn du später eine Pages Function zum selben Projekt hinzufügst, bekommen ihre Antworten diese Header nicht. Setze sie im Code der Function.

## Wenn du zu Workers wechselst

Der Umzug der Site zu Workers Static Assets ändert, wie sie deployt wird, nicht, wie das Formular funktioniert. Das Formular sendet weiterhin an ikiform.com, also verarbeitet nichts auf Cloudflares Seite den Post. Prüfe nach dem Umzug zwei Dinge: Der neue `workers.dev`-Host oder die Route steht unter **Allowed domains**, und deine Header erreichen weiterhin die Seite, da Workers sie auf eigene Weise setzt.

> Füge `your-project.pages.dev` und deine eigene Domain unter **Allowed domains** hinzu. Füge niemals `pages.dev` allein hinzu: Jedes Pages-Projekt würde passen.

## Prüfen, ob es funktioniert

1. Pushe einen Branch und öffne seine Preview-URL. Sende ab und prüfe, ob die Nachricht ankommt und nicht als Spam markiert ist: Die Preview ist eine Subdomain des Hosts deines Projekts.
2. Sende in der Produktion erneut ab, geh dann zurück und sende dasselbe Formular ein zweites Mal, ohne das Widget abzuschließen. Erwarte die Meldung zum Zurücksetzen, keine gespeicherte Einsendung.
3. Öffne **Submissions** im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
4. Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und **Reply** geht an den Absender, wenn das Formular ein `email`-Feld hat.
5. Nichts angekommen? Stell den Statusfilter im Dashboard auf **Spam**. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `405` mit leerem Body | Das Formular hat an eine Pages-URL gesendet. Prüfe die deployte `action` und ob keine Function oder Redirect-Regel `/contact` umschreibt. |
| Das Turnstile-Widget erscheint nie | Die Content-Security-Policy blockiert `challenges.cloudflare.com`. Füge es zu `script-src` und `frame-src` hinzu und prüfe, ob `_headers` im Build-Output liegt. |
| `403 captcha_failed` bei jedem Post | Site Key und Secret stammen von verschiedenen Widgets, der Hostname ist beim Widget nicht eingetragen, oder ein Test-Secret ist mit einem echten Site Key kombiniert. |
| `403 origin_not_allowed`, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter **Allowed domains** des Formulars. Füge sie hinzu, plus `localhost` für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |

## Fragen

### Brauche ich Pages Functions für ein Kontaktformular auf Cloudflare Pages?

Nein. Eine Function ist ein Weg, den Post anzunehmen, aber dann schreibst du Validierung, E-Mail-Versand und Spamfilter selbst. Ein Post an ein Formular-Backend hält das Projekt vollständig statisch.

### Brauchen Preview-Deployments eine eigene erlaubte Domain?

Nein. Preview-URLs von Pages sind Subdomains von your-project.pages.dev, wer diesen Host erlaubt, deckt also alle ab.

### Kann ich mein bestehendes Turnstile-Widget mit Ikiform nutzen?

Ja. Setze die Spamprüfung des Formulars auf deine eigenen Turnstile-Schlüssel und füge Site Key und Secret ein. Ikiform verifiziert jedes Token und prüft seinen Hostnamen gegen deine erlaubten Domains.

### Warum bekommt mein Formular auf Cloudflare Pages einen 405?

Es hat an deine eigene Pages-URL gesendet, die statische Assets ausliefert, die nur GET-Requests beantworten. Richte die action des Formulars auf ein Formular-Backend aus oder füge eine Pages Function für diesen Pfad hinzu.

### Ist Ikiform kostenlos?

Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.

## Weitere Anleitungen

- [Kontaktformular auf einer statischen Site](https://ikiform.com/de/guides/contact-form-static-site.md): Bau ein funktionierendes Kontaktformular in eine statische Site ohne Server ein: wohin der POST geht, Spam, Antworten per E-Mail und Code für jeden Host und jedes Framework.
- [Vercel](https://ikiform.com/de/guides/contact-form-static-site/vercel.md): Eine statische Seite auf Vercel beantwortet einen Formular-POST mit 405 Method Not Allowed; hier steht, warum, und wie du das Formular stattdessen an ein Backend sendest.
- [Netlify](https://ikiform.com/de/guides/contact-form-static-site/netlify.md): Betreib ein Kontaktformular auf einer Netlify-Site ohne Netlify Forms: keine Erkennung beim Build, JSON-Posts aus React und ein eigenes Formular für Deploy Previews.
- [GitHub Pages](https://ikiform.com/de/guides/contact-form-static-site/github-pages.md): GitHub Pages kann keinen Servercode ausführen, also bekommt ein Formular-POST 405 Not Allowed; sende an ein Formular-Backend und setze Weiterleitungen für Projekt-Sites richtig.
- [Amazon S3](https://ikiform.com/de/guides/contact-form-static-site/aws-s3.md): S3-Website-Endpunkte akzeptieren nur GET und HEAD über einfaches HTTP, also sende das Kontaktformular an ein Formular-Backend und setze Weiterleitungen mit CloudFront richtig.
- [Portfolio-Website](https://ikiform.com/de/guides/contact-form-static-site/portfolio-website.md): Ein Portfolio-Kontaktformular mit den Fragen, die du im ersten Gespräch stellen würdest: Projekt, Budget, Zeitplan und ein Link, ohne Server und mit Antworten aus deinem Postfach.
