# Ajouter un formulaire de contact à un site Cloudflare Pages

> Un formulaire de contact sur Cloudflare Pages sans écrire de Function : envoyez à un backend, définissez les en-têtes dans _headers, utilisez vos clés Turnstile.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/contact-form-static-site/cloudflare-pages

Les ressources statiques de Cloudflare Pages répondent `405` à un `POST` : écrivez une Pages Function, ou envoyez le formulaire à un backend de formulaires. Avec Ikiform, vous n'écrivez aucune Function : réglez l'`action` du formulaire sur votre endpoint, ajoutez `your-project.pages.dev` aux **Allowed domains**, ce qui couvre aussi toutes les URL de préproduction, et listez Ikiform dans la Content-Security-Policy de votre `_headers`. Si vous utilisez déjà Cloudflare, vous pouvez brancher vos propres clés Turnstile sur le formulaire.

## Ce qui change sur Cloudflare Pages

Pages sert le résultat de votre build depuis le réseau de Cloudflare. Les ressources statiques répondent à `GET` ; un `POST` vers l'une d'elles reçoit `405` sans corps :

`Réponse à un POST`:

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

Pages peut exécuter du code, via les Pages Functions d'un dossier `functions/`. Un `functions/contact.ts` pourrait accepter l'envoi, mais vous écririez alors un backend : analyse, validation, fournisseur d'e-mail, filtrage du spam et stockage. La documentation de Cloudflare recommande aussi de démarrer les nouveaux projets sur Workers plutôt que Pages. Envoyer vers un backend de formulaires évite les deux, et le même formulaire fonctionne tel quel si vous passez plus tard aux ressources statiques de Workers.

| Particularité de Pages | Conséquence pour le formulaire |
| --- | --- |
| Les URL de préproduction sont des sous-domaines, comme `abc123.your-project.pages.dev` | Un seul domaine autorisé, `your-project.pages.dev`, couvre la production et toutes les préproductions. Les préproductions Vercel et Netlify demandent leurs propres entrées |
| Les en-têtes viennent d'un fichier `_headers` dans le résultat du build | Jusqu'à 100 règles, 2 000 caractères par ligne. Ils ne s'appliquent pas aux réponses des Pages Functions |
| Turnstile est un produit Cloudflare | Vous pouvez utiliser la vérification gérée d'Ikiform sans clés, ou votre propre widget depuis le tableau de bord Cloudflare |

Schéma : Un formulaire de contact sur Cloudflare Pages avec vos propres clés Turnstile

1. Pages sert la page de contact, avec la Content-Security-Policy de votre fichier _headers.
2. Le widget Turnstile s'exécute avec votre clé de site et place un jeton dans le formulaire.
3. Le formulaire envoie ses champs et le jeton à Ikiform.
4. Ikiform vérifie le jeton avec votre clé secrète et compare le nom d'hôte du jeton à vos domaines autorisés.
5. Un envoi valide est stocké et envoyé par e-mail. Un jeton absent, expiré ou réutilisé reçoit 403 captcha_failed, et la page réinitialise le widget.

Avec la vérification gérée d'Ikiform, il n'y a pas de widget sur votre page ; le formulaire HTML n'a besoin d'aucune modification.

## Le formulaire

Cette version utilise votre propre widget Turnstile, ce qui a du sens si vous gérez déjà Turnstile dans votre compte Cloudflare. Dans les réglages du formulaire, réglez **Spam check** sur **Your own Turnstile keys** et collez la clé de site et le secret. Le widget ajoute un champ `cf-turnstile-response` au formulaire, et le select `team` vous indique qui doit répondre :

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

Le script garde les visiteurs sur la page. Un jeton Turnstile est à usage unique : après tout envoi échoué, il réinitialise donc le widget pour la tentative suivante :

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

- Dans les réglages du widget Turnstile chez Cloudflare, listez les noms d'hôte où tourne le formulaire. Ikiform compare aussi le nom d'hôte du jeton aux **Allowed domains** du formulaire.
- Les envois sans jeton valide sont refusés avec `403 captcha_failed`. Cela inclut un envoi HTML classique parti avant que le widget n'ait terminé.
- Vous préférez ne gérer aucune clé ? Réglez **Spam check** sur **Ikiform**, supprimez le widget et son script, et l'envoi HTML passe la vérification sur ikiform.com. La version `fetch` a alors besoin de `challenge()` du [SDK](https://ikiform.com/fr/docs/sdk.md) ; voir [la vérification humaine](https://ikiform.com/fr/docs/spam.md#human-check).

## Vos clés ou la vérification d'Ikiform

|   | Vérification d'Ikiform | Vos propres clés Turnstile |
| --- | --- | --- |
| Clés à gérer | Aucune | Une clé de site et un secret depuis votre tableau de bord Cloudflare |
| Modification du formulaire HTML | Aucune. La vérification s'exécute sur une page ikiform.com | Ajouter le widget et son script |
| Version `fetch` | Appeler `challenge()` du SDK | Envoyer le `cf-turnstile-response` du widget |
| Content-Security-Policy | Seulement `form-action` et `connect-src` pour Ikiform | Aussi `challenges.cloudflare.com` dans `script-src` et `frame-src` |
| Statistiques Turnstile | Pas dans votre compte | Dans votre tableau de bord Cloudflare, à côté de vos autres widgets |

La vérification d'Ikiform demande moins de configuration. Vos propres clés ont du sens si vous suivez déjà les statistiques Turnstile, ou si vous voulez maîtriser l'apparence et l'emplacement du widget.

### Tester le widget en local

Cloudflare publie des clés de site et des secrets de test qui réussissent ou échouent toujours, comme la paire toujours valide `1x00000000000000000000AA` et `1x0000000000000000000000000000000AA`. Les secrets de production rejettent les jetons créés avec une clé de test : ne les mélangez donc pas sur un même formulaire. Créez un second formulaire Ikiform pour le travail en local, donnez-lui la paire de clés de test, et changez ensemble le `data-sitekey` et l'id du formulaire. Laissez vides les **Allowed domains** de ce formulaire : Ikiform ne vérifie le nom d'hôte d'un jeton que lorsque la liste est définie, et Cloudflare ne documente pas le nom d'hôte que renvoie un jeton de test.

## Le fichier _headers

Placez `_headers` dans votre répertoire de ressources statiques, comme `public/`, pour qu'il se retrouve dans le résultat du build. Turnstile a besoin de `script-src` et `frame-src` pour `https://challenges.cloudflare.com`, et Ikiform de `form-action` et `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
```

Si vous ajoutez plus tard une Pages Function au même projet, ses réponses ne reçoivent pas ces en-têtes. Définissez-les dans le code de la Function.

## Si vous passez à Workers

Passer le site sur les ressources statiques de Workers change la façon dont il se déploie, pas le fonctionnement du formulaire. Le formulaire envoie toujours vers ikiform.com, donc rien côté Cloudflare ne le traite. Vérifiez deux choses après la migration : le nouvel hôte `workers.dev` ou la nouvelle route figure dans **Allowed domains**, et vos en-têtes atteignent toujours la page, puisque Workers a sa propre façon de les définir.

> Ajoutez `your-project.pages.dev` et votre domaine personnalisé dans **Allowed domains**. N'ajoutez jamais `pages.dev` seul : tous les projets Pages correspondraient.

## Vérifiez que tout fonctionne

1. Poussez une branche et ouvrez son URL de préproduction. Envoyez, et vérifiez que le message arrive sans être marqué comme spam : la préproduction est un sous-domaine de l'hôte de votre projet.
2. Envoyez à nouveau en production, puis revenez en arrière et renvoyez le même formulaire sans compléter le widget. Attendez-vous au message de réinitialisation, pas à une soumission stockée.
3. Ouvrez **Submissions** dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
4. Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et **Reply** répond à l'expéditeur quand le formulaire a un champ `email`.
5. Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur **Spam**. Le spam reçoit la même réponse de succès qu'une vraie soumission.

## Pièges

| Problème | Que faire |
| --- | --- |
| `405` avec un corps vide | Le formulaire a envoyé vers une URL Pages. Vérifiez l'`action` déployée, et qu'aucune Function ni règle de redirection ne réécrit `/contact`. |
| Le widget Turnstile n'apparaît jamais | La Content-Security-Policy bloque `challenges.cloudflare.com`. Ajoutez-le à `script-src` et `frame-src`, et vérifiez que `_headers` est bien dans le résultat du build. |
| `403 captcha_failed` à chaque envoi | La clé de site et le secret viennent de widgets différents, le nom d'hôte n'est pas listé sur le widget, ou un secret de test est associé à une vraie clé de site. |
| `403 origin_not_allowed`, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les **Allowed domains** du formulaire. Ajoutez-le, plus `localhost` pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |

## Questions

### Ai-je besoin des Pages Functions pour un formulaire de contact sur Cloudflare Pages ?

Non. Une Function est une façon d'accepter l'envoi, mais vous écrivez alors vous-même la validation, l'envoi d'e-mails et le filtrage du spam. Envoyer vers un backend de formulaires garde le projet entièrement statique.

### Les déploiements de préproduction ont-ils besoin de leur propre domaine autorisé ?

Non. Les URL de préproduction de Pages sont des sous-domaines de your-project.pages.dev : autoriser cet hôte les couvre toutes.

### Puis-je utiliser mon widget Turnstile existant avec Ikiform ?

Oui. Réglez la vérification anti-spam du formulaire sur vos propres clés Turnstile et collez la clé de site et le secret. Ikiform vérifie chaque jeton et compare son nom d'hôte à vos domaines autorisés.

### Pourquoi mon formulaire reçoit-il 405 sur Cloudflare Pages ?

Il a envoyé vers votre propre URL Pages, qui sert des ressources statiques ne répondant qu'aux requêtes GET. Faites pointer l'action du formulaire vers un backend de formulaires, ou ajoutez une Pages Function pour ce chemin.

### Ikiform est-il gratuit ?

Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.

## Autres guides

- [Formulaire de contact sur un site statique](https://ikiform.com/fr/guides/contact-form-static-site.md): Ajoutez un formulaire de contact fonctionnel à un site statique sans serveur : où va le POST, le spam, les réponses par e-mail, et du code pour chaque hébergeur.
- [Vercel](https://ikiform.com/fr/guides/contact-form-static-site/vercel.md): Une page statique sur Vercel répond à un POST de formulaire par 405 Method Not Allowed ; voici pourquoi, et comment envoyer le formulaire à un backend.
- [Netlify](https://ikiform.com/fr/guides/contact-form-static-site/netlify.md): Un formulaire de contact sur Netlify sans Netlify Forms : pas de détection au build, envois JSON depuis React, et un formulaire à part pour les deploy previews.
- [GitHub Pages](https://ikiform.com/fr/guides/contact-form-static-site/github-pages.md): GitHub Pages n'exécute pas de code serveur, un POST de formulaire reçoit donc 405 Not Allowed ; envoyez vers un backend et réglez bien les redirections.
- [Amazon S3](https://ikiform.com/fr/guides/contact-form-static-site/aws-s3.md): Les website endpoints S3 n'acceptent que GET et HEAD en HTTP simple : envoyez le formulaire à un backend et gérez bien les redirections avec CloudFront.
- [Site portfolio](https://ikiform.com/fr/guides/contact-form-static-site/portfolio-website.md): Un formulaire de contact de portfolio qui pose les questions d'un premier appel : projet, budget, délai et un lien, sans serveur et avec réponses depuis votre boîte.
