Dépasser les formulaires intégrés8 min de lecture
Limite de Netlify Forms : ce qui se passe à 100 soumissions, et comment en sortir
Les anciennes offres Netlify comptent les soumissions de formulaire par site et vous font passer automatiquement au niveau supérieur, et les offres à crédits peuvent mettre le site en pause, alors déplacez le formulaire vers un backend.
Sur les anciennes offres de Netlify, chaque site inclut 100 soumissions de formulaire par mois, et les dépasser fait passer automatiquement le site à un niveau Forms payant. Sur les offres à crédits des comptes créés depuis septembre 2025, les formulaires sont gratuits, mais une équipe gratuite à court de crédits voit tous ses sites mis en pause, formulaires compris. Déplacer le formulaire vers Ikiform consiste à retirer les attributs Netlify et à définir l'action, en HTML simple ou dans n'importe quel framework.
Ce qui change sur Netlify
Netlify n'est pas un créateur de sites ; c'est un hébergeur avec des formulaires intégrés. Cela détermine à la fois la limite et la façon d'en sortir. La limite qui s'applique dépend de la date de création de votre compte :
| Compte | Ce que dit la documentation de Netlify | Ce que cela signifie pour un formulaire très sollicité |
|---|---|---|
| Anciennes offres, créées avant le 4 septembre 2025 | Forms Level 0 inclut 100 soumissions et 10 Mo d'envois de fichiers par site et par mois. Le dépasser fait passer le site à Forms Level 1, avec 1 000 soumissions, facturé en fin de cycle de facturation. Au-delà, des forfaits d'utilisation supplémentaires ajoutent 500 soumissions à la fois | Une vague de spam peut faire passer un site à un niveau payant sans que personne ne l'ait décidé. Sur l'ancien niveau gratuit, atteindre une limite met en pause tous les sites du compte |
| Offres à crédits, créées depuis | Les soumissions de formulaire sont gratuites et illimitées, sans niveaux | Le formulaire lui-même ne coûte rien. Mais quand une équipe gratuite épuise ses crédits mensuels, Netlify met en pause tous ses projets, et les projets en pause ne reçoivent pas de soumissions de formulaire |
La documentation de Netlify indique clairement que le passage au niveau supérieur sur les anciennes offres est automatique et que vous ne pouvez pas redescendre tant que l'utilisation dépasse le niveau inférieur. Vérifiez le type d'offre de votre équipe et l'utilisation des formulaires du site dans le tableau de bord Netlify avant de décider quoi que ce soit.
La seconde raison de migrer n'a rien à voir avec les chiffres. Netlify Forms fonctionne en lisant votre HTML déployé :
- Les formulaires sont détectés au déploiement. Un formulaire rendu par React, Vue ou Svelte n'existe pas dans ce HTML, donc vous gardez une copie statique masquée que le build peut trouver, et vous y envoyez avec un champ
form-name. - Avec Next.js, l'adaptateur OpenNext a besoin de cette copie sous forme de fichier statique dans
public/, et l'envoi doit aller vers ce fichier statique, pas vers une page Next.js. Le build échoue sidata-netlifyapparaît dans le code React sans cette copie. - Les soumissions doivent être en urlencoded ou multipart. Le JSON n'est pas accepté.
- Le formulaire ne fonctionne que tant que le site est déployé sur Netlify. Changer d'hébergeur implique de déplacer les formulaires.
- Sur une ancienne offre, un site commence le mois au Forms Level 0 avec 100 soumissions incluses.
- La soumission 101 arrive. La documentation de Netlify sur l'utilisation décrit l'utilisation du site, donc une rafale de spam peut suffire à le faire dépasser.
- Netlify fait passer le site à Forms Level 1, avec 1 000 soumissions, et le facture en fin de cycle.
- Au-delà du Level 1, des forfaits d'utilisation supplémentaires ajoutent 500 soumissions à la fois.
- Avec le formulaire qui envoie vers Ikiform, la page reste sur Netlify mais l'envoi part vers l'endpoint Ikiform.
- Netlify Forms ne reçoit jamais l'envoi, donc il n'a rien à compter ni à faire passer au niveau supérieur.
Déplacer le formulaire, stack par stack
Avant de modifier le moindre code, créez un formulaire Ikiform par nom de formulaire Netlify et réglez son Honeypot field sur bot-field. Votre input masqué existant continue alors de servir de honeypot, sans renommage. Choisissez votre stack :
Le HTML simple sur Netlify demande le moins de changements. Supprimez les attributs Netlify et l'input form-name, faites pointer action vers l'endpoint, et déplacez le chemin de remerciement dans _redirect sous forme d'URL complète. L'input bot-field reste votre honeypot.
<form name="contact" method="POST" action="/thanks" data-netlify="true" netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="contact" />
<p hidden><input name="bot-field" /></p>
<label>Email <input type="email" name="email" required /></label>
<label>Topic
<select name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
</label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>Sur Netlify, un formulaire Next.js ne fonctionnait avec Netlify Forms que via une copie statique dans public/, comme public/__forms.html, vers laquelle votre composant envoyait. Supprimez ce fichier, retirez tout paramètre NETLIFY_NEXT_VERIFY_FORMS, et envoyez depuis le composant vers Ikiform. Le formulaire envoie aussi sans JavaScript, grâce à son action.
"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [state, setState] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState("sending");
setError("");
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) return setState("sent");
setError(result.error.fields?.message?.[0] ?? result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't send. Check your connection and try again.");
}
setState("idle");
}
if (state === "sent") return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{error ? <p role="alert">{error}</p> : null}
<button disabled={state === "sending"}>{state === "sending" ? "Sending" : "Send"}</button>
</form>
);
}Une page Astro statique n'a besoin d'aucun JavaScript. Retirez data-netlify du formulaire, définissez l'endpoint comme action, et construisez la redirection à partir de site dans astro.config.mjs. La page peut rester prérendue.
---
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const thanksUrl = new URL("/thanks", Astro.site).href;
---
<form method="post" action={endpoint}>
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<input type="hidden" name="_redirect" value={thanksUrl} />
<button>Send</button>
</form>Une app TanStack Start sur Netlify rend les formulaires en React, ce que la détection au déploiement de Netlify ne pouvait pas voir. Envoyez plutôt via une server function, et supprimez la copie statique masquée. Définissez IKIFORM_SECRET_KEY dans les variables d'environnement du site sur Netlify. La fonction transmet l'en-tête Origin du navigateur, donc les envois depuis localhost comptent comme tests gratuits tant que votre espace de travail n'a pas d'offre.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? "").trim(),
topic: String(data.get("topic") ?? "question"),
message: String(data.get("message") ?? "").trim(),
"bot-field": String(data.get("bot-field") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
return result.ok ? { sent: true, error: "" } : { sent: false, error: String(result.error.message) };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setState(await send({ data: new FormData(event.currentTarget) }));
} catch {
setState({ sent: false, error: "Couldn't send. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send"}</button>
</form>
);
}Avec React Router en framework mode, l'action de la route transmet l'envoi. Le FormData passe sans modification, donc bot-field atteint la vérification honeypot d'Ikiform, et l'en-tête Origin du navigateur suit pour que les envois depuis localhost comptent comme tests. Retirez la copie statique que vous gardiez pour Netlify.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: form,
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
return data({ sent: false, error: String(result.error.message) }, { status: response.status });
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
if (actionData?.sent) return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<Form method="post">
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{actionData?.error ? <p role="alert">{actionData.error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send"}</button>
</Form>
);
}Une page SvelteKit sur Netlify peut envoyer directement depuis le navigateur, sans form action de votre côté. L'attribut action garde le formulaire fonctionnel avant l'hydratation. Supprimez toute copie prérendue du formulaire ajoutée pour Netlify.
<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let sent = $state(false);
let sending = $state(false);
let error = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
sending = true;
error = "";
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) sent = true;
else error = result.error.message;
} catch {
error = "Couldn't send. Check your connection and try again.";
}
sending = false;
}
</script>
{#if sent}
<p role="status">Thanks. We read every message and reply within a day.</p>
{:else}
<form method="post" action={ENDPOINT} onsubmit={send}>
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
{#if error}<p role="alert">{error}</p>{/if}
<button disabled={sending}>{sending ? "Sending" : "Send"}</button>
</form>
{/if}Dans Nuxt, le composant envoie depuis le navigateur avec $fetch, donc il n'y a pas de route serveur à déployer. $fetch lève une exception sur une réponse autre que 2xx, et l'enveloppe d'erreur d'Ikiform se trouve dans le data de l'erreur. Retirez le formulaire statique masqué de public/ et le champ form-name.
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sent = ref(false);
const sending = ref(false);
const error = ref("");
async function send(event: Event) {
sending.value = true;
error.value = "";
try {
await $fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
sent.value = true;
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string } } }).data;
error.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We read every message and reply within a day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send" }}</button>
</form>
</template>Une application React monopage sur Netlify envoyait des données urlencoded vers / avec un champ form-name, et avait besoin d'une copie statique dans index.html pour la détection. Elle envoie maintenant le FormData vers l'endpoint. Seule la requête change :
const body = new URLSearchParams(new FormData(event.currentTarget) as unknown as Record<string, string>);
body.set("form-name", "contact");
await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});Supprimez le <form netlify> masqué de index.html une fois que plus rien n'envoie vers Netlify.
Le même envoi côté navigateur dans un composant Vue monofichier. Retirez de index.html le formulaire masqué qui permettait à Netlify de le détecter.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const state = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: Event) {
state.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
state.value = "sent";
return;
}
error.value = result.error.message;
} catch {
error.value = "Couldn't send. Check your connection and try again.";
}
state.value = "idle";
}
</script>
<template>
<p v-if="state === 'sent'" role="status">Thanks. We read every message and reply within a day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="state === 'sending'">{{ state === "sending" ? "Sending" : "Send" }}</button>
</form>
</template>IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Chaque version garde les mêmes noms de champs que sous Netlify, donc les e-mails de notification et les colonnes CSV restent familiers. La comparaison avec Netlify Forms contient la correspondance complète des attributs, y compris data-netlify-recaptcha et les inputs de fichier.
Deploy previews et domaines autorisés
Netlify donne à chaque pull request sa propre adresse, comme deploy-preview-42--your-site.netlify.app. Ce n'est pas un sous-domaine de your-site.netlify.app, donc lister l'adresse de votre site sous Allowed domains ne couvre pas les previews, et les envois depuis une preview finissent en spam avec origin_mismatch.
- Ajoutez
your-site.netlify.appet votre domaine personnalisé pour la production. - Ajoutez un hôte de preview précis pendant que vous le testez, puis retirez-le.
- N'ajoutez pas
netlify.appseul. Tous les sites sur Netlify seraient autorisés à envoyer vers votre formulaire. - Ou faites pointer les previews vers un second formulaire Ikiform sans domaines autorisés, via une variable d'environnement, et gardez la production stricte.
Basculer sans perdre de soumissions
- Téléchargez les soumissions passées de chaque formulaire en CSV depuis la section Forms du site. Ikiform n'a pas d'import.
- Déployez le nouveau formulaire sur une preview et envoyez un test. Vérifiez les noms de champs dans le tableau de bord Ikiform.
- Fusionnez en production et envoyez un autre test depuis le domaine de production.
- Remplacez tout webhook sortant de Netlify par un webhook Ikiform. Netlify signe avec un jeton JWS ; Ikiform utilise les en-têtes Standard Webhooks, donc votre vérificateur change aussi.
- Quand aucun envoi n'a atteint Netlify depuis quelques jours, désactivez la détection de formulaires dans les paramètres Forms du site.
422 files_not_supported. Si un formulaire a besoin de fichiers, gardez ce formulaire-là sur Netlify Forms et déplacez les autres.Vérifier que tout fonctionne sur Netlify
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Déployez une preview, ajoutez son hôte et envoyez le formulaire depuis cette preview.
- Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Vérifiez que les champs sont
email,topicetmessage, sansform-namenibot-field. - Ouvrez l'onglet Forms du site dans Netlify et vérifiez que le test n'y figure pas.
- Envoyez une fois avec
bot-fieldrempli. L'envoi est stocké comme spam et ne déclenche rien. - 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.
Pièges
| Problème | Que faire |
|---|---|
Un champ form-name apparaît dans Ikiform | L'input masqué form-name est toujours dans le formulaire, ou votre code le définit encore. Retirez-le ; Ikiform identifie le formulaire par l'URL. |
| Le build Next.js échoue en mentionnant les formulaires | Un attribut netlify ou data-netlify est encore dans votre code React. Retirez-le, ainsi que public/__forms.html. |
| Les previews envoient en spam | L'hôte de preview ne figure pas dans Allowed domains. Ajoutez-le pendant vos tests, ou utilisez un second formulaire pour les previews. |
| Chaque envoi est marqué comme spam, même les vrais | Honeypot field est encore _gotcha alors que votre input est bot-field, ou l'inverse. Faites-les correspondre. |
422 files_not_supported | Le formulaire a un <input type="file"> avec un fichier choisi. L'envoi de fichiers n'est pas encore pris en charge ; retirez l'input ou demandez plutôt un lien. |
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
Sur les anciennes offres, Netlify fait passer le site à Forms Level 1, qui inclut 1 000 soumissions par mois, et le facture en fin de cycle de facturation. Sur l'ancien niveau gratuit, atteindre une limite met en pause tous les sites du compte jusqu'à ce que vous passiez à une offre supérieure.
La documentation de Netlify indique que les soumissions de formulaire sont gratuites et illimitées sur les offres à crédits. Le piège, c'est le site lui-même : quand une équipe gratuite épuise ses crédits mensuels, ses projets sont mis en pause et ne reçoivent pas de soumissions de formulaire.
Oui. Peu importe à Ikiform où la page est hébergée. Retirez les attributs Netlify du formulaire, définissez son action sur votre endpoint Ikiform, et Netlify Forms cesse de le voir.
Non. Cette copie n'existe que pour que l'étape de déploiement de Netlify trouve le formulaire. Ikiform reçoit les envois sur une URL, donc supprimez la copie et envoyez depuis votre composant.
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.