# Ajouter un formulaire de contact à un site statique sans backend

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

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

Un hébergeur statique sert des fichiers et refuse un POST de formulaire, généralement avec `405` ou `404` : le formulaire doit donc envoyer ailleurs. Faites pointer l'`action` du formulaire vers un backend de formulaires comme Ikiform, qui stocke chaque message, filtre le spam et vous l'envoie par e-mail avec l'expéditeur comme adresse de réponse. Votre site reste statique : pas de code serveur, pas de clé secrète dans la page et rien ne change dans votre déploiement.

Un site statique est un dossier de HTML, CSS et JavaScript qu'un hébergeur distribue sous forme de fichiers. C'est pour cela qu'il est rapide, bon marché et difficile à casser. C'est aussi pour cela que le formulaire de contact est la première chose qui cesse de fonctionner : une soumission de formulaire est une requête que quelque chose doit recevoir, et un hébergeur statique n'a rien à l'écoute.

## Pourquoi le formulaire échoue sur un hébergeur statique

Un `<form method="post">` sans `action` envoie vers la page où il se trouve. Sur un hébergeur statique, c'est un fichier, et les fichiers ne répondent qu'à `GET` et `HEAD`. Nous avons envoyé un `POST` à une page statique en ligne sur chacun des grands hébergeurs le 2026-10-25. Aucun n'a rien stocké :

| Hébergeur | Ce que reçoit un POST vers une page statique |
| --- | --- |
| GitHub Pages | `405 Not Allowed`, sous forme de petite page HTML |
| Vercel | `405` avec un corps vide |
| Netlify | `404`, avec la page 404 du site s'il en a une, sauf si Netlify Forms a détecté ce formulaire au déploiement |
| Cloudflare Pages | `405` avec un corps vide |
| Endpoint de site web Amazon S3 | `405 Method Not Allowed`, avec le code `MethodNotAllowed` |

La solution est partout la même : envoyer vers une URL qui accepte le POST. Cette URL peut être du code que vous écrivez et hébergez, une fonctionnalité de votre hébergeur ou un backend de formulaires. La suite de ce guide utilise un backend de formulaires, car il garde le site statique.

Schéma : Où va l'envoi d'un formulaire de contact, avant et après la correction

1. Sans action, ou avec une action qui pointe vers un chemin de votre propre site, le navigateur envoie à l'hébergeur statique.
2. L'hébergeur n'a aucun code à exécuter pour un POST : il répond 405 ou 404 et le message est perdu.
3. Avec l'action pointant vers votre endpoint Ikiform, l'envoi part vers un serveur qui l'accepte.
4. Ikiform vérifie le spam, stocke le message et vous l'envoie par e-mail.
5. Ikiform répond par une redirection 303, et le navigateur charge la page de remerciement de votre propre site.

Seule l'action du formulaire change. La page, l'hébergeur et le déploiement restent tels quels.

## Ce qui arrive à chaque message

Ikiform est l'endpoint vers lequel le formulaire envoie. Il n'héberge pas votre page et n'affiche pas votre formulaire ; vous gardez les deux. Pour chaque envoi, il lance les vérifications anti-spam, stocke le message et le diffuse aux endroits où vous le lisez.

Schéma : Ce qu'Ikiform fait d'un message de formulaire de contact

1. Un visiteur remplit le formulaire de contact, qui l'envoie à votre endpoint Ikiform.
2. Ikiform vérifie le honeypot, la limite de débit et le score de spam, puis stocke le message.
3. Chaque destinataire des notifications reçoit un e-mail, avec l'adresse de l'expéditeur comme adresse de réponse.
4. Le message apparaît dans le tableau de bord, où vous pouvez le rechercher, restaurer le spam et exporter en CSV.
5. Si vous ajoutez un webhook, Ikiform envoie une copie signée vers Slack, Discord, un outil d'automatisation ou votre propre code.

L'e-mail suffit pour la plupart des formulaires de contact. Ajoutez un webhook quand les messages doivent arriver là où vous travaillez déjà.

## Cinq façons de recevoir le formulaire d'un site statique

| Approche | Points forts | Compromis |
| --- | --- | --- |
| Un lien `mailto:` ou `action="mailto:..."` | Aucune configuration. Convient à une page personnelle avec peu de trafic. | Ouvre l'appli de messagerie du visiteur, s'il en a configuré une. Rien n'est stocké ni filtré, et votre adresse est dans le HTML, à la portée des scrapers. |
| La fonctionnalité de formulaires de votre hébergeur, comme [Netlify Forms](https://ikiform.com/alternatives/netlify-forms) | Un seul attribut, pas d'autre compte, et généralement inclus dans l'offre d'hébergement. | Ne fonctionne que chez cet hébergeur. Changer d'hébergeur oblige à refaire le formulaire, et les formulaires rendus en JavaScript demandent des contournements. |
| Une fonction serverless que vous écrivez | Contrôle total sur la validation, le stockage et l'e-mail envoyé. | Vous gérez désormais un backend : une clé d'API d'e-mail, le filtrage du spam, les limites de débit, les nouvelles tentatives et les logs. Chaque hébergeur a son propre format de fonction. |
| Un formulaire hébergé intégré, comme [Google Forms](https://ikiform.com/alternatives/google-forms), [Tally](https://ikiform.com/alternatives/tally) ou [Typeform](https://ikiform.com/alternatives/typeform) | Pas de code. Bien pour les formulaires longs ou en plusieurs étapes. | Une iframe ou un lien vers un autre site, avec leur apparence et leurs scripts sur votre page. |
| Un backend de formulaires, comme Ikiform, [Formspree](https://ikiform.com/alternatives/formspree), [Basin](https://ikiform.com/alternatives/basin) ou [Web3Forms](https://ikiform.com/alternatives/web3forms) | Votre propre HTML sur n'importe quel hébergeur. Stockage, filtrage du spam et e-mail sans serveur. | Un compte et une facture de plus, et les données du formulaire sont chez ce service. |

Si vous êtes sur Netlify et n'avez aucune intention d'en partir, Netlify Forms est imbattable en termes d'effort. Si vous voulez écrire le backend, une fonction est un bon projet de week-end. Si vous voulez que le formulaire continue de fonctionner quand vous changez d'hébergeur ou de framework, utilisez un backend de formulaires. Les [comparatifs](https://ikiform.com/alternatives) mettent les backends côte à côte.

## Le formulaire

Tous les exemples ci-dessous envoient depuis le navigateur, car un déploiement statique n'a pas de serveur depuis lequel envoyer. Choisissez votre stack ; ce choix s'applique à tous les exemples de code de ces guides. Les générateurs de sites statiques ont leurs propres pages de configuration : [Jekyll](https://ikiform.com/fr/docs/guides/jekyll.md), [Hugo](https://ikiform.com/fr/docs/guides/hugo.md), [Eleventy](https://ikiform.com/fr/docs/guides/eleventy.md) et [Gatsby](https://ikiform.com/fr/docs/guides/gatsby.md).

**HTML**

C'est toute l'intégration pour un site écrit à la main. Le formulaire envoie de façon classique, il fonctionne donc avec JavaScript désactivé, et le navigateur arrive sur votre `thanks.html` après un envoi réussi.

`contact.html`:

```html
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" maxlength="120" required />
  <p class="field-error" data-error-for="name" hidden></p>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <p class="field-error" data-error-for="email" hidden></p>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
  <p class="field-error" data-error-for="message" hidden></p>

  <input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send message</button>
  <p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>

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

Le script est facultatif. Il garde le visiteur sur la page, place chaque erreur de champ sous son input, et laisse le message saisi en place en cas d'échec.

`contact.js`:

```js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");

function showErrors(fields = {}) {
  for (const slot of form.querySelectorAll("[data-error-for]")) {
    const name = slot.dataset.errorFor;
    const message = fields[name]?.[0] ?? "";
    slot.textContent = message;
    slot.hidden = !message;
    form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  status.textContent = "Sending";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.hidden = true;
      done.hidden = false;
      return;
    }
    showErrors(result.error.fields);
    status.textContent = result.error.message;
  } catch {
    status.textContent = "Couldn't send. Check your connection and try again.";
  }
  button.disabled = false;
});
```

**Next.js**

Avec `output: "export"`, `next build` écrit des fichiers simples dans `out/`. Les Server Actions et les Route Handlers qui lisent la requête ne sont pas pris en charge dans un export statique : le formulaire envoie donc depuis le navigateur.

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;
```

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;

export function ContactForm() {
  const [sentTo, setSentTo] = useState("");
  const [sending, setSending] = useState(false);
  const [errors, setErrors] = useState<Errors>({});
  const [notice, setNotice] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setSending(true);
    setNotice("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) {
        setSentTo(String(body.get("email")));
        return;
      }
      const fields = result.error.fields ?? {};
      setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
      setNotice(result.error.message);
    } catch {
      setNotice("Couldn't send. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
      {errors.name ? <p>{errors.name}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
      {errors.email ? <p>{errors.email}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
      {errors.message ? <p>{errors.message}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {notice ? <p role="alert">{notice}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Affichez `<ContactForm />` depuis `app/contact/page.tsx`. Le formulaire garde son `action`, il envoie donc même avant le chargement du bundle JavaScript.

**Astro**

Astro pré-rend chaque page par défaut, ce qui convient à un hébergeur statique. N'utilisez pas les Astro Actions ici : envoyer un formulaire vers une action exige un rendu à la demande et un adaptateur. Envoyez vers Ikiform, et laissez le `<script>` intégré garder les visiteurs sur la page.

`src/pages/contact.astro`:

```astro
---
import Layout from "../layouts/Layout.astro";

const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---

<Layout title="Contact">
  <form id="contact" action={endpoint} 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="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required></textarea>
    <input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send message</button>
    <output id="contact-status" role="status"></output>
  </form>
</Layout>

<script>
  const form = document.querySelector<HTMLFormElement>("#contact")!;
  const status = document.querySelector<HTMLOutputElement>("#contact-status")!;

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    status.value = "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: "Thanks. Your message is on its way, and I'll reply by email." }));
      return;
    }
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.entries(fields)[0];
    status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
  });
</script>
```

`Astro.site` vient de `site` dans `astro.config.mjs`. Réglez-le sur votre adresse `https:` pour que la valeur de `_redirect` soit une URL complète qu'Ikiform peut suivre.

**TanStack Start**

Le mode SPA construit une coquille rendue côté client que n'importe quel hébergeur statique peut servir. Les server functions ont toujours besoin d'un serveur, un déploiement statique ne peut donc pas les exécuter ; la route envoie depuis le navigateur et passe à `/thanks` en cas de succès.

`vite.config.ts`:

```ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    tanstackStart({
      spa: {
        enabled: true,
      },
    }),
    viteReact(),
  ],
});
```

`src/routes/contact.tsx`:

```tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const navigate = useNavigate();
  const [problems, setProblems] = useState<string[]>([]);
  const [sending, setSending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) {
        await navigate({ to: "/thanks" });
        return;
      }
      const fields: Record<string, string[]> = result.error.fields ?? {};
      setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
    } catch {
      setProblems(["Couldn't send. Check your connection and try again."]);
    }
    setSending(false);
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      {problems.length ? (
        <ul role="alert">
          {problems.map((problem) => (
            <li key={problem}>{problem}</li>
          ))}
        </ul>
      ) : null}
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**React Router**

Avec `ssr: false`, il n'y a pas de build serveur, une route ne peut donc pas avoir d'`action` serveur. Un `clientAction` s'exécute dans le navigateur à la place : il transmet l'envoi du `<Form>` à Ikiform et redirige en cas de succès.

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```

`app/routes/contact.tsx`:

```tsx
import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export async function clientAction({ request }: Route.ClientActionArgs) {
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: await request.formData(),
  });
  const result = await response.json();
  if (result.ok) return redirect("/thanks");
  return {
    fields: (result.error.fields ?? {}) as Record<string, string[]>,
    message: String(result.error.message),
  };
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";
  const error = (name: string) => actionData?.fields[name]?.[0];

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name") ? <p>{error("name")}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email") ? <p>{error("email")}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      {error("message") ? <p>{error("message")}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

**SvelteKit**

Avec `adapter-static` et `prerender = true`, chaque page est un fichier. Les form actions de SvelteKit sont exclues : les pages avec actions ne peuvent pas être pré-rendues, car un serveur doit traiter leur POST. Le composant envoie avec `fetch` à la place.

`src/routes/+layout.ts`:

```ts
export const prerender = true;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

  let phase = $state<"idle" | "sending" | "sent">("idle");
  let errors = $state<Record<string, string[]>>({});
  let notice = $state("");

  async function send(event: SubmitEvent) {
    event.preventDefault();
    phase = "sending";
    notice = "";
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget as HTMLFormElement),
      });
      const result = await response.json();
      if (result.ok) {
        phase = "sent";
        return;
      }
      errors = result.error.fields ?? {};
      notice = result.error.message;
    } catch {
      notice = "Couldn't send. Check your connection and try again.";
    }
    phase = "idle";
  }
</script>

{#if phase === "sent"}
  <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
  <form action={ENDPOINT} method="post" onsubmit={send}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
    {#if errors.name}<p>{errors.name[0]}</p>{/if}
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
    {#if errors.email}<p>{errors.email[0]}</p>{/if}
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
    {#if errors.message}<p>{errors.message[0]}</p>{/if}
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if notice}<p role="alert">{notice}</p>{/if}
    <button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

**Nuxt**

`nuxt generate` écrit les pages pré-rendues dans `.output/public`. Les routes de `server/api` n'existent pas sur un hébergeur statique : le composant appelle donc directement Ikiform avec `$fetch` et passe à `/thanks` en cas de succès.

`app/pages/contact.vue`:

```vue
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);

async function send() {
  sending.value = true;
  notice.value = "";
  try {
    await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
    await navigateTo("/thanks");
  } catch (failure) {
    const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
    errors.value = data?.error?.fields ?? {};
    notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="form.name" name="name" autocomplete="name" required />
    <p v-if="errors.name">{{ errors.name[0] }}</p>
    <label for="email">Email</label>
    <input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label for="message">Message</label>
    <textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
    <p v-if="errors.message">{{ errors.message[0] }}</p>
    <input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="notice" role="alert">{{ notice }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

**React**

Une application monopage Vite sur un hébergeur statique. L'endpoint est dans une variable `VITE_` pour que les builds de préproduction et de production puissent pointer vers des formulaires différents ; il finit de toute façon dans le bundle, ce qui ne pose pas de problème car ce n'est pas un secret.

`.env.production`:

```text
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

`src/ContactForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };

export function ContactForm() {
  const [status, setStatus] = useState<Status>({ kind: "idle" });

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setStatus({ kind: "sending" });
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) return setStatus({ kind: "sent" });
      const fields: Record<string, string[]> = result.error.fields ?? {};
      const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
      setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
    } catch {
      setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
    }
  }

  if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status.kind === "failed" ? (
        <div role="alert">
          {status.lines.map((line) => (
            <p key={line}>{line}</p>
          ))}
        </div>
      ) : null}
      <button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

Le même envoi côté navigateur dans un composant monofichier Vue, qui lit l'endpoint dans la même variable `VITE_`. Seule la première erreur s'affiche, à côté du bouton.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { computed, ref } from "vue";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);

async function send(event: SubmitEvent) {
  sending.value = true;
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    sent.value = result.ok;
    fields.value = result.ok ? {} : (result.error.fields ?? {});
    notice.value = result.ok ? "" : result.error.message;
  } catch {
    notice.value = "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="firstError" role="alert">{{ firstError }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Remplacez `frm_xxxxxxxxxxxxxxxx` par l'id de votre formulaire, visible dans le tableau de bord. L'id est public par conception : il est dans votre HTML dès le déploiement. Ne mettez jamais de clé d'API secrète dans un site statique.
- Gardez `email` comme nom du champ e-mail. Ikiform en fait l'adresse de réponse des e-mails de notification et le compare aux domaines d'e-mails jetables.
- L'input masqué `_gotcha` est le honeypot. Les humains ne le voient jamais ; les bots qui remplissent tous les inputs se signalent eux-mêmes comme spam.
- Les versions JavaScript envoient `Accept: application/json` : Ikiform répond donc `201` en cas de succès, ou `422` avec un objet `error.fields` qui nomme chaque champ et le problème.

Si votre framework tourne bien sur un serveur en production, vous pouvez envoyer depuis celui-ci et garder l'endpoint hors de la page. Les guides [Next.js](https://ikiform.com/fr/docs/guides/nextjs.md), [SvelteKit](https://ikiform.com/fr/docs/guides/sveltekit.md) et [Nuxt](https://ikiform.com/fr/docs/guides/nuxt.md) montrent les versions serveur, et [Depuis un serveur](https://ikiform.com/fr/docs/guides/server.md) couvre les clés secrètes.

> Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore **Allowed domains** et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la [limite de débit](https://ikiform.com/fr/docs/rate-limits.md) Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez `IKIFORM_SECRET_KEY` dans des variables d'environnement réservées au serveur.

## Les champs d'un formulaire de contact

Nom, e-mail et message couvrent presque tous les formulaires de contact. Un select de sujet vaut la peine quand des personnes différentes répondent à des types de messages différents. Essayez le formulaire ci-dessous : il exécute le validateur d'Ikiform dans votre navigateur et montre ce qui serait stocké, ou le `422` que vous recevriez.

Formulaire : Contact

- **Nom** (`name`, text, obligatoire)
- **E-mail** (`email`, email, obligatoire)
- **Sujet** (`topic`, select)
- **Message** (`message`, textarea, obligatoire)

La même définition que le [modèle de formulaire de contact](https://ikiform.com/forms/contact-form). Collez-la dans l'onglet **Fields** du formulaire et Ikiform l'applique côté serveur, quoi que dise le HTML.

| Champ | Pourquoi | À garder en tête |
| --- | --- | --- |
| `name` | Pour saluer la personne dans la réponse | Un lien dans un champ nom est un fort signal de spam : gardez donc l'id `name` |
| `email` | Où part la réponse | Obligatoire. `type="email"` affiche le bon clavier sur mobile |
| `topic` | Envoyer les ventes et le support à la bonne personne | Un select n'accepte que les valeurs listées, on peut donc filtrer dessus en toute sécurité |
| `message` | La raison d'être du formulaire | Une longueur minimale de 10 empêche les envois d'un seul mot |

Laissez de côté les numéros de téléphone, la taille de l'entreprise et tout ce que vous n'utiliserez pas dans votre première réponse. Chaque champ ajouté vous coûte des messages. Sans définition de champs, Ikiform stocke tous les inputs nommés que le formulaire envoie ; avec une définition, il rejette les envois non conformes.

> L'envoi de fichiers n'est pas encore pris en charge. Un envoi avec un fichier reçoit `422 files_not_supported`.

## Répondre depuis votre boîte

Les e-mails de notification vont jusqu'à 10 destinataires confirmés par formulaire. Quand le formulaire a un champ `email`, l'adresse de réponse de l'e-mail est l'expéditeur : **Reply** dans votre appli de messagerie lui répond donc directement. Vous ne copiez jamais d'adresse depuis un tableau de bord.

Les notifications sont plafonnées à 50 e-mails par formulaire et par heure, puis un récapitulatif horaire, pour qu'une vague de spam ne puisse pas noyer votre boîte. Les messages marqués comme spam n'envoient aucun e-mail. `_subject` ne change pas l'objet des e-mails de notification. Il est transmis aux webhooks et à l'API sous `meta.subject`.

> Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. Les e-mails de notification vont uniquement à vous et à votre équipe : il n'y a donc ni réponse automatique, ni e-mail de confirmation, ni double opt-in. Envoyez-les depuis votre outil d'e-mailing, déclenché par un [webhook](https://ikiform.com/fr/docs/webhooks.md).

Vous voulez les messages dans un chat d'équipe au lieu de l'e-mail, ou en plus ? Ajoutez un webhook et suivez [Slack](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md) ou [Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md).

## Ce que voit le visiteur après l'envoi

Un envoi HTML classique se termine par une redirection. Ikiform répond à un envoi réussi depuis un navigateur par `303 See Other`, et le navigateur charge la cible avec un `GET`, que votre hébergeur statique sert sans problème.

Schéma : Les trois requêtes derrière l'envoi d'un formulaire HTML classique

1. Le navigateur envoie les champs du formulaire à votre endpoint Ikiform.
2. Ikiform stocke le message et répond 303 See Other avec un en-tête Location.
3. Le navigateur suit la redirection avec un GET vers votre page de remerciement, que l'hébergeur statique sert comme n'importe quel fichier.

Sans cible de redirection, l'étape 3 charge la page de remerciement hébergée par Ikiform au lieu de la vôtre.

Ikiform choisit la cible dans cet ordre :

- **Redirect after submit** dans les réglages du formulaire. N'importe quelle URL `http:` ou `https:` fonctionne, car seule votre équipe peut la définir. C'est le choix le plus simple pour un site unique.
- Un champ `_redirect` dans le formulaire, s'il s'agit d'une URL `https:` sur l'un des **Allowed domains** du formulaire. Utilisez-le quand un formulaire sert plusieurs sites ou plusieurs langues.
- Sinon, la page de remerciement hébergée par Ikiform, qui renvoie vers votre site quand l'envoi venait d'une page `https:` autorisée.

Faites de la page de remerciement un vrai fichier et excluez-la des résultats de recherche. Indiquez quand vous répondrez ; cela vous évite un second e-mail de la même personne.

`thanks.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <title>Message sent</title>
  </head>
  <body>
    <h1>Thanks, your message is on its way</h1>
    <p>I read every message and reply within two working days, from the address on this site.</p>
    <p><a href="/">Back to the home page</a></p>
  </body>
</html>
```

Les versions JavaScript ci-dessus se passent de redirection. Un envoi avec `Accept: application/json` reçoit du JSON en retour, et la page affiche elle-même la phrase de succès.

## Le spam sur un formulaire statique

Votre endpoint est dans le code source de votre page, et les bots lisent le code source. Attendez-vous à du spam dans les jours qui suivent la mise en ligne. Ikiform superpose ses défenses pour que vous puissiez démarrer sans rien configurer :

| Défense | Ce qu'elle arrête | Configuration |
| --- | --- | --- |
| Honeypot | Les bots qui remplissent tous les inputs | L'input masqué `_gotcha` |
| Score de spam | Des liens dans le nom (+3), beaucoup de liens (+2), des formules de spam connues (+2) et des adresses jetables. À 5, un message est stocké comme spam | Aucune |
| Limites de débit | Les scripts qui envoient en boucle | Activée par défaut : Standard, 5 par minute et par visiteur |
| Domaines autorisés | Les copies de votre formulaire qui envoient depuis d'autres sites | Ajoutez vos domaines dans les réglages du formulaire |
| Vérification humaine | Les bots qui pilotent un vrai navigateur | Réglez **Spam check** sur **Ikiform** pour Cloudflare Turnstile sans clés |

Le spam reçoit la même réponse de succès qu'un vrai message, pour que le bot n'apprenne rien, et n'envoie ni e-mail ni webhook. Un formulaire de contact a rarement besoin de plus d'un envoi par personne en quelques minutes : Strict, 2 toutes les 10 minutes par visiteur, lui convient une fois en ligne. La [documentation anti-spam](https://ikiform.com/fr/docs/spam.md) couvre chaque couche, et la vérification humaine demande un appel de plus dans les versions JavaScript : `challenge()` du [SDK](https://ikiform.com/fr/docs/sdk.md).

### Domaines autorisés et déploiements de préproduction

**Allowed domains** correspond à un domaine et à ses sous-domaines. Les hébergeurs statiques vous donnent plus de domaines que vous ne le pensez : le domaine de production, le sous-domaine propre à l'hébergeur, et une adresse de préproduction par branche ou par pull request. Certaines préproductions sont des sous-domaines de l'adresse de votre site chez l'hébergeur et correspondent ; d'autres non, et demandent leur propre entrée.

| Hébergeur | À ajouter | Préproductions |
| --- | --- | --- |
| GitHub Pages | `username.github.io` et votre domaine personnalisé | Pas de préproduction par défaut |
| Vercel | `your-project.vercel.app` et votre domaine | Les URL de branche ne correspondent pas ; ajoutez l'hôte exact pendant vos tests |
| Netlify | `your-site.netlify.app` et votre domaine | `deploy-preview-12--your-site.netlify.app` ne correspond pas |
| Cloudflare Pages | `your-project.pages.dev` et votre domaine | `abc123.your-project.pages.dev` correspond déjà |

N'ajoutez jamais seul un hôte partagé, comme `github.io`, `vercel.app` ou `netlify.app`. Tous les sites de cet hébergeur correspondraient, y compris ceux des autres.

## Si votre site envoie une Content-Security-Policy

Beaucoup de modèles statiques livrent une `Content-Security-Policy` stricte. Deux directives décident si le formulaire fonctionne : `form-action` pour l'envoi HTML classique, et `connect-src` pour les versions `fetch`. Oubliez l'une ou l'autre et le navigateur bloque la requête avant qu'elle ne quitte la page, avec pour seul indice un message dans la console.

`En-tête de réponse`:

```text
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

Gardez `'self'` dans `form-action` : certains navigateurs vérifient aussi la redirection vers votre page de remerciement par rapport à cette directive. L'endroit où se trouve l'en-tête dépend de l'hébergeur : `_headers` sur Netlify et Cloudflare Pages, `vercel.json` sur Vercel, une balise `<meta>` sur GitHub Pages, et une politique d'en-têtes de réponse CloudFront devant S3. Les pages par hébergeur ci-dessous montrent chacune.

## Tester avant de déployer

Ouvrez la page via un serveur local, pas en double-cliquant sur le fichier. Une page ouverte en `file://` envoie `Origin: null`, qui ne correspond à aucun domaine autorisé et ne compte pas comme un test local. N'importe laquelle de ces commandes sert le dossier courant :

`Terminal`:

```bash
npx serve .

python3 -m http.server 8000
```

Ajoutez `localhost` aux **Allowed domains** pendant vos tests. Les envois depuis `localhost` comptent comme des tests : un espace de travail sans offre en a 50 par mois, vous pouvez donc construire tout le formulaire avant de payer.

## Choisissez votre cas

Le formulaire ci-dessus fonctionne sur tous les hébergeurs statiques. Chaque hébergeur a sa propre façon d'échouer, ses propres domaines de préproduction et ses fichiers d'en-têtes. Ces pages les couvrent.

- [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.
- [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.
- [Cloudflare Pages](https://ikiform.com/fr/guides/contact-form-static-site/cloudflare-pages.md): 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.
- [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.

## Vérifiez que tout fonctionne

Faites ces vérifications une fois sur votre domaine en ligne et une fois sur une préproduction, si votre hébergeur en crée. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis `localhost`. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les [tarifs](https://ikiform.com/fr/pricing).

1. Dans **Allowed domains** du formulaire, ajoutez le domaine de votre site, plus `localhost` pendant le développement. Laissez vide pour accepter les envois de partout.
2. Envoyez depuis votre serveur local et vérifiez que le message apparaît sous **Submissions**.
3. Déployez, puis envoyez depuis le site en ligne avec JavaScript désactivé. Vous devez arriver sur votre propre page de remerciement.
4. Réactivez JavaScript et envoyez avec un message vide. L'erreur doit apparaître sur la page, pas sous forme de page d'erreur hébergée.
5. 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`.
6. Envoyez avec le honeypot rempli, `_gotcha=bot`. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.
7. 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.

## Dépannage

| Problème | Que faire |
| --- | --- |
| `405 Method Not Allowed` ou `404` juste après avoir cliqué sur Envoyer | Le formulaire a envoyé vers votre propre site. Vérifiez l'attribut `action` dans le HTML déployé, pas dans vos sources ; une étape de build ou un composant `<Form>` de framework l'a peut-être remplacé. |
| Rien ne se passe, et la console mentionne `Content-Security-Policy` | Ajoutez `https://ikiform.com` à `form-action` pour les envois classiques et à `connect-src` pour `fetch`. |
| `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. |
| Les tests en local sont refusés avec `402 plan_required` | La page a été ouverte via une URL `file://` : l'envoi n'avait donc pas d'origine exploitable et a compté comme un envoi réel. Servez le dossier avec un serveur local et testez depuis `localhost`. |
| Le navigateur arrive sur la page de remerciement d'Ikiform au lieu de la vôtre | `_redirect` est ignoré sauf si son domaine figure dans **Allowed domains** et qu'il s'agit d'une URL `https:`. Ajoutez le domaine, ou définissez plutôt **Redirect after submit** dans les réglages du formulaire. |
| `422 validation_failed` | L'envoi ne correspond pas à la définition des champs du formulaire. `error.fields` nomme chaque champ et la raison. Faites correspondre le `name` de l'input à l'id du champ. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre **Spam** pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |
| `429 rate_limited` | Un visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de `Retry-After`, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée. |

## Questions

### Un site statique peut-il avoir un formulaire de contact fonctionnel ?

Oui. La page reste statique ; seule l'action du formulaire pointe vers un service qui accepte les envois. Un backend de formulaires stocke chaque message, filtre le spam et vous l'envoie par e-mail : vous ne gérez jamais de serveur.

### Est-il sûr de mettre l'endpoint du formulaire dans du HTML public ?

Oui. Un id de formulaire est fait pour être public, comme une adresse e-mail sur une page de contact. Protégez le formulaire avec les domaines autorisés, le honeypot, les limites de débit et la vérification humaine. Ce qui ne doit jamais se trouver dans un site statique, c'est une clé d'API secrète.

### Pourquoi ne pas utiliser un lien mailto : ?

Il ne fonctionne que si le visiteur a configuré une appli de messagerie, il ne stocke rien, et il met votre adresse dans la page à la portée des scrapers. Un formulaire garde le visiteur sur votre site et vous laisse une trace de chaque message.

### Puis-je utiliser mail() de PHP sur GitHub Pages ou Netlify ?

Non. Les hébergeurs statiques n'exécutent ni PHP ni aucun autre code serveur pour vos pages. Un script mail en PHP a besoin d'un serveur, ce qu'un site statique évite justement.

### Le formulaire peut-il accepter des envois de fichiers ?

Pas encore. Un envoi contenant un fichier est refusé avec 422 files_not_supported. Demandez plutôt un lien vers le fichier, comme une URL de drive partagé.

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

- [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.
- [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.
- [Cloudflare Pages](https://ikiform.com/fr/guides/contact-form-static-site/cloudflare-pages.md): 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.
- [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.
- [Recevoir un formulaire par e-mail](https://ikiform.com/fr/guides/send-form-to-email.md): Recevez chaque soumission dans votre boîte sans serveur mail : pourquoi mailto : et mail() de PHP échouent, et une solution qui filtre le spam et permet de répondre.
- [Arrêter le spam des formulaires de contact](https://ikiform.com/fr/guides/spam-protection.md): Arrêtez le spam des formulaires de contact avec des couches invisibles pour les vrais visiteurs : honeypot, délai, score du contenu, limites de débit, vérification humaine si besoin.
- [Dépasser les formulaires intégrés](https://ikiform.com/fr/guides/website-builder-forms.md): Quand les formulaires de Webflow, Netlify, Carrd ou Framer atteignent un plafond ou manquent d'une fonctionnalité, gardez le formulaire que vous avez conçu et faites-le pointer vers un backend de formulaires.
