Guides17 min de lecture
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.
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.
- Sans action, ou avec une action qui pointe vers un chemin de votre propre site, le navigateur envoie à l'hébergeur statique.
- L'hébergeur n'a aucun code à exécuter pour un POST : il répond 405 ou 404 et le message est perdu.
- Avec l'action pointant vers votre endpoint Ikiform, l'envoi part vers un serveur qui l'accepte.
- Ikiform vérifie le spam, stocke le message et vous l'envoie par e-mail.
- Ikiform répond par une redirection 303, et le navigateur charge la page de remerciement de votre propre site.
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.
- Un visiteur remplit le formulaire de contact, qui l'envoie à votre endpoint Ikiform.
- Ikiform vérifie le honeypot, la limite de débit et le score de spam, puis stocke le message.
- Chaque destinataire des notifications reçoit un e-mail, avec l'adresse de l'expéditeur comme adresse de réponse.
- Le message apparaît dans le tableau de bord, où vous pouvez le rechercher, restaurer le spam et exporter en CSV.
- Si vous ajoutez un webhook, Ikiform envoie une copie signée vers Slack, Discord, un outil d'automatisation ou votre propre code.
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 | 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, Tally ou 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, Basin ou 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 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, Hugo, Eleventy et Gatsby.
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.
<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.
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;
});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.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"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 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.
---
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.
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.
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(),
],
});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>
);
}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.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;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>
);
}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.
export const prerender = true;<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 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.
<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>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.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { 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>
);
}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.
<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_xxxxxxxxxxxxxxxxpar 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
emailcomme 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é
_gotchaest 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 donc201en cas de succès, ou422avec un objeterror.fieldsqui 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, SvelteKit et Nuxt montrent les versions serveur, et Depuis un serveur couvre les clés secrètes.
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.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
| 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.
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.
Vous voulez les messages dans un chat d'équipe au lieu de l'e-mail, ou en plus ? Ajoutez un webhook et suivez Slack, Discord ou Telegram.
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.
- Le navigateur envoie les champs du formulaire à votre endpoint Ikiform.
- Ikiform stocke le message et répond 303 See Other avec un en-tête Location.
- Le navigateur suit la redirection avec un GET vers votre page de remerciement, que l'hébergeur statique sert comme n'importe quel fichier.
Ikiform choisit la cible dans cet ordre :
- Redirect after submit dans les réglages du formulaire. N'importe quelle URL
http:ouhttps:fonctionne, car seule votre équipe peut la définir. C'est le choix le plus simple pour un site unique. - Un champ
_redirectdans le formulaire, s'il s'agit d'une URLhttps: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.
<!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 couvre chaque couche, et la vérification humaine demande un appel de plus dans les versions JavaScript : challenge() du SDK.
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.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comGardez '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 :
npx serve .
python3 -m http.server 8000Ajoutez 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 PagesGitHub 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.
- VercelUne page statique sur Vercel répond à un POST de formulaire par 405 Method Not Allowed ; voici pourquoi, et comment envoyer le formulaire à un backend.
- NetlifyUn 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 PagesUn 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 S3Les 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 portfolioUn 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.
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Envoyez depuis votre serveur local et vérifiez que le message apparaît sous Submissions.
- Déployez, puis envoyez depuis le site en ligne avec JavaScript désactivé. Vous devez arriver sur votre propre page de remerciement.
- 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.
- 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. - 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. - 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
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.
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.
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.
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.
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é.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.