Vá além dos formulários do criador8 min de leitura
Limite do Netlify Forms: o que acontece nos 100 envios, e a saída
Planos legados da Netlify medem envios de formulário por site e fazem upgrade automático, e planos por créditos podem pausar o site; leve o formulário para um backend.
Nos planos legados da Netlify, cada site inclui 100 envios de formulário por mês, e passar disso sobe o site automaticamente para um nível pago de Forms. Nos planos por créditos, para contas criadas desde setembro de 2025, os formulários são grátis, mas um time gratuito que fica sem créditos tem todos os sites pausados, formulários incluídos. Levar o formulário para o Ikiform significa remover os atributos da Netlify e definir o action, em HTML simples ou em qualquer framework.
O que muda na Netlify
A Netlify não é um criador de sites; é um host com formulários embutidos. Isso define tanto o limite quanto a saída. Qual limite vale depende de quando a sua conta foi criada:
| Conta | O que a documentação da Netlify diz | O que isso significa para um formulário movimentado |
|---|---|---|
| Planos legados, criados antes de 4 de setembro de 2025 | O Forms Level 0 inclui 100 envios e 10 MB de uploads por site por mês. Passar disso sobe o site para o Forms Level 1, com 1.000 envios, cobrado no fim do ciclo de cobrança. Além disso, pacotes de uso extra adicionam 500 por vez | Uma onda de spam pode levar um site a um nível pago sem ninguém decidir isso. No plano legado gratuito, atingir um limite pausa todos os sites da conta |
| Planos por créditos, criados desde então | Envios de formulário são grátis e ilimitados, sem níveis | O formulário em si não custa nada. Mas quando um time gratuito usa os créditos mensais, a Netlify pausa todos os projetos dele, e projetos pausados não recebem envios de formulário |
A própria documentação da Netlify deixa claro que o upgrade legado é automático e que você não pode voltar para baixo enquanto o uso estiver acima do nível inferior. Confira o tipo de plano do seu time e o uso de formulários do site no painel da Netlify antes de decidir qualquer coisa.
O segundo motivo pelo qual as pessoas migram não tem nada a ver com números. O Netlify Forms funciona lendo o seu HTML publicado:
- Os formulários são encontrados no deploy. Um formulário renderizado por React, Vue ou Svelte não existe nesse HTML, então você mantém uma cópia estática oculta para o build encontrar e envia para ela com um campo
form-name. - Com Next.js, o adapter OpenNext precisa dessa cópia como arquivo estático em
public/, e o envio precisa ir para esse arquivo estático, não para uma página do Next.js. O build falha sedata-netlifyaparecer no código React sem essa cópia. - Os envios precisam ser urlencoded ou multipart. JSON não é aceito.
- O formulário só funciona enquanto o site está publicado na Netlify. Trocar de host significa trocar de formulários.
- Num plano legado, um site começa o mês no Forms Level 0 com 100 envios incluídos.
- Chega o envio 101. A documentação de uso da Netlify descreve o uso do site, então uma rajada de spam pode ser o que o faz passar do limite.
- A Netlify sobe o site para o Forms Level 1, com 1.000 envios, e cobra no fim do ciclo.
- Além do Level 1, pacotes de uso extra adicionam 500 envios por vez.
- Com o formulário enviando ao Ikiform, a página continua na Netlify, mas o envio vai para o endpoint do Ikiform.
- O Netlify Forms nunca recebe o envio, então não há nada para contar nem para fazer upgrade.
Migre o formulário, stack por stack
Antes de mudar qualquer código, crie um formulário no Ikiform para cada nome de formulário da Netlify e defina o Honeypot field dele como bot-field. Assim o input oculto que você já tem continua funcionando como honeypot, sem renomear nada. Escolha sua stack:
HTML simples na Netlify é o que exige menos mudança. Apague os atributos da Netlify e o input form-name, aponte o action para o endpoint e passe o caminho de agradecimento para _redirect como URL completa. O input bot-field continua como seu 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>Na Netlify, um formulário Next.js só funcionava com o Netlify Forms por meio de uma cópia estática em public/, como public/__forms.html, para a qual o seu componente enviava. Apague esse arquivo, remova qualquer configuração NETLIFY_NEXT_VERIFY_FORMS e envie do componente ao Ikiform. O formulário também envia sem JavaScript, graças ao 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>
);
}Uma página estática do Astro não precisa de nenhum JavaScript. Remova data-netlify do formulário, defina o endpoint como action e monte o redirecionamento a partir de site em astro.config.mjs. A página pode continuar pré-renderizada.
---
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>Um app TanStack Start na Netlify renderiza formulários em React, que a detecção no deploy da Netlify não conseguia ver. Envie por uma server function e descarte a cópia estática oculta. Defina IKIFORM_SECRET_KEY nas variáveis de ambiente do site na Netlify. A função repassa o header Origin do navegador, então envios de localhost contam como testes grátis antes de o seu workspace ter um plano.
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>
);
}Com React Router em modo framework, a action da rota encaminha o envio. O FormData passa sem mudanças, então bot-field chega à verificação de honeypot do Ikiform, e o header Origin do navegador vai junto para envios de localhost contarem como testes. Remova a cópia estática que você mantinha para a 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>
);
}Uma página SvelteKit na Netlify pode enviar direto do navegador, sem uma form action própria. O atributo action mantém o formulário funcionando antes da hidratação. Apague qualquer cópia pré-renderizada do formulário que você adicionou para a 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}No Nuxt, o componente envia do navegador com $fetch, então não há server route para publicar. $fetch lança erro numa resposta que não seja 2xx, e o envelope de erro do Ikiform fica no data do erro. Remova o formulário estático oculto de public/ e o campo 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>Uma single-page app React na Netlify enviava dados urlencoded para / com um campo form-name e precisava de uma cópia estática em index.html para a detecção. Agora ela envia o FormData para o endpoint. Só a requisição muda:
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(),
});Apague o <form netlify> oculto de index.html quando nada mais enviar para a Netlify.
O mesmo envio pelo navegador num single-file component do Vue. Remova de index.html o formulário oculto que permitia à Netlify detectá-lo.
<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 em variáveis de ambiente só do servidor.Todas as versões mantêm os mesmos nomes de campo que a Netlify tinha, então os e-mails de notificação e as colunas do CSV parecem familiares. A comparação com o Netlify Forms tem o mapeamento completo de atributos, incluindo data-netlify-recaptcha e inputs de arquivo.
Deploy previews e domínios permitidos
A Netlify dá a cada pull request um endereço próprio, como deploy-preview-42--your-site.netlify.app. Isso não é subdomínio de your-site.netlify.app, então listar o endereço do seu site em Allowed domains não cobre os previews, e os envios dos previews caem no spam com origin_mismatch.
- Adicione
your-site.netlify.appe o seu domínio personalizado para produção. - Adicione um host de preview específico enquanto o testa, e depois remova.
- Não adicione
netlify.appsozinho. Qualquer site na Netlify poderia enviar ao seu formulário. - Ou aponte os previews para um segundo formulário do Ikiform sem domínios permitidos, por uma variável de ambiente, e mantenha a produção restrita.
Faça a virada sem perder envios
- Baixe os envios antigos de cada formulário como CSV na seção Forms do site. O Ikiform não tem importação.
- Publique o novo formulário num preview e envie um teste. Confira os nomes dos campos no painel do Ikiform.
- Faça o merge para produção e envie outro teste a partir do domínio de produção.
- Troque qualquer webhook de saída da Netlify por um webhook do Ikiform. A Netlify assina com um token JWS; o Ikiform usa headers do Standard Webhooks, então o seu verificador também muda.
- Quando nenhum envio tiver chegado à Netlify por alguns dias, desligue a detecção de formulários nas configurações de Forms do site.
422 files_not_supported. Se um formulário precisa de arquivos, mantenha esse formulário no Netlify Forms e migre o resto.Confira se funciona na Netlify
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Publique um preview, adicione o host dele e envie o formulário a partir dele.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Confirme que os campos são
email,topicemessage, semform-namee sembot-field. - Abra a aba Forms do site na Netlify e confirme que o teste não está lá.
- Envie uma vez com
bot-fieldpreenchido. Ele é armazenado como spam e não dispara nada. - Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email.
Pegadinhas
| Problema | O que fazer |
|---|---|
Um campo form-name aparece no Ikiform | O input oculto form-name ainda está no formulário, ou o seu código ainda o define. Remova; o Ikiform identifica o formulário pela URL. |
| O build do Next.js falha mencionando formulários | Ainda há um atributo netlify ou data-netlify no seu código React. Remova junto com public/__forms.html. |
| Os previews enviam para o spam | O host do preview não está em Allowed domains. Adicione enquanto testa, ou use um segundo formulário para os previews. |
| Todo envio é marcado como spam, até os reais | O Honeypot field ainda está como _gotcha enquanto o seu input é bot-field, ou o contrário. Faça os dois baterem. |
422 files_not_supported | O formulário tem um <input type="file"> com um arquivo escolhido. Upload de arquivos ainda não é suportado; remova o input ou peça um link. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
Perguntas
Nos planos legados, a Netlify sobe o site para o Forms Level 1, que inclui 1.000 envios por mês, e cobra no fim do ciclo de cobrança. No plano legado gratuito, atingir um limite pausa todos os sites da conta até você fazer upgrade.
A documentação da Netlify diz que envios de formulário são grátis e ilimitados nos planos por créditos. O problema é o próprio site: quando um time gratuito fica sem os créditos mensais, os projetos dele são pausados e não recebem envios de formulário.
Sim. O Ikiform não se importa com onde a página está hospedada. Remova os atributos da Netlify do formulário, defina o action dele como o seu endpoint do Ikiform, e o Netlify Forms deixa de enxergá-lo.
Não. Essa cópia só existe para que a etapa de deploy da Netlify encontre o formulário. O Ikiform recebe envios numa URL, então apague a cópia e envie a partir do seu componente.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.