Más allá de los formularios del creador8 min de lectura
Límite de Netlify Forms: qué pasa a los 100 envíos y cómo salir
Los planes antiguos de Netlify miden los envíos de formularios por sitio y te suben de nivel automáticamente, y los planes por créditos pueden pausar el sitio, así que lleva el formulario a un backend.
En los planes antiguos de Netlify, cada sitio incluye 100 envíos de formulario al mes, y superarlos sube automáticamente el sitio a un nivel de Forms de pago. En los planes por créditos de las cuentas creadas desde septiembre de 2025, los formularios son gratis, pero un equipo gratuito que se queda sin créditos tiene todos sus sitios pausados, formularios incluidos. Llevar el formulario a Ikiform significa quitar los atributos de Netlify y poner el action, en HTML puro o en cualquier framework.
Qué cambia en Netlify
Netlify no es un creador de sitios; es un hosting con formularios integrados. Eso determina tanto el límite como la salida. El límite que se aplica depende de cuándo se creó tu cuenta:
| Cuenta | Qué dice la documentación de Netlify | Qué significa para un formulario con mucho tráfico |
|---|---|---|
| Planes antiguos, creados antes del 4 de septiembre de 2025 | Forms Level 0 incluye 100 envíos y 10 MB de subidas por sitio y mes. Superarlo sube el sitio a Forms Level 1, con 1.000 envíos, cobrado al final del ciclo de facturación. Por encima, los paquetes de uso extra añaden 500 cada vez | Una ola de spam puede pasar un sitio a un nivel de pago sin que nadie lo decida. En el nivel antiguo gratuito, alcanzar un límite pausa todos los sitios de la cuenta |
| Planes por créditos, creados desde entonces | Los envíos de formularios son gratis e ilimitados, sin niveles | El formulario en sí no cuesta nada. Pero cuando un equipo gratuito gasta sus créditos mensuales, Netlify pausa todos sus proyectos, y los proyectos pausados no reciben envíos de formularios |
La propia documentación de Netlify deja claro que la subida de nivel antigua es automática y que no puedes volver a bajar mientras el uso esté por encima del nivel inferior. Comprueba el tipo de plan de tu equipo y el uso de formularios del sitio en el panel de Netlify antes de decidir nada.
El segundo motivo por el que la gente se va no tiene nada que ver con las cifras. Netlify Forms funciona leyendo tu HTML desplegado:
- Los formularios se detectan al desplegar. Un formulario renderizado por React, Vue o Svelte no existe en ese HTML, así que mantienes una copia estática oculta para que la encuentre el build, y envías a ella con un campo
form-name. - Con Next.js, el adaptador OpenNext necesita esa copia como archivo estático en
public/, y el envío debe ir a ese archivo estático, no a una página de Next.js. El build falla sidata-netlifyaparece en código React sin esa copia. - Los envíos deben ser urlencoded o multipart. No se acepta JSON.
- El formulario solo funciona mientras el sitio esté desplegado en Netlify. Cambiar de hosting significa mover los formularios.
- En un plan antiguo, un sitio empieza el mes en Forms Level 0 con 100 envíos incluidos.
- Llega el envío 101. La documentación de uso de Netlify describe el uso del sitio, así que una racha de spam puede ser lo que lo haga superar el límite.
- Netlify sube el sitio a Forms Level 1, con 1.000 envíos, y lo cobra al final del ciclo.
- Por encima del Level 1, los paquetes de uso extra añaden 500 envíos cada vez.
- Con el formulario enviando a Ikiform, la página sigue en Netlify pero el envío va al endpoint de Ikiform.
- Netlify Forms nunca recibe el envío, así que no hay nada que contar ni que subir de nivel.
Mueve el formulario, stack por stack
Antes de cambiar código, crea un formulario de Ikiform por cada nombre de formulario de Netlify y pon su Honeypot field en bot-field. Así tu input oculto actual sigue funcionando como honeypot, sin renombrarlo. Elige tu stack:
El HTML puro en Netlify es lo que menos cambios necesita. Borra los atributos de Netlify y el input form-name, apunta action al endpoint y pasa la ruta de agradecimiento a _redirect como URL completa. El input bot-field se queda como 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>En Netlify, un formulario de Next.js solo funcionaba con Netlify Forms mediante una copia estática en public/, como public/__forms.html, a la que enviaba tu componente. Borra ese archivo, quita cualquier ajuste NETLIFY_NEXT_VERIFY_FORMS y envía desde el componente a Ikiform. El formulario también envía sin JavaScript, gracias a su 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>
);
}Una página estática de Astro no necesita nada de JavaScript. Quita data-netlify del formulario, pon el endpoint como action y construye la redirección a partir de site en astro.config.mjs. La página puede seguir prerenderizada.
---
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>Una app de TanStack Start en Netlify renderiza los formularios en React, algo que la detección de Netlify al desplegar no podía ver. Envía en su lugar mediante una server function y quita la copia estática oculta. Define IKIFORM_SECRET_KEY en las variables de entorno del sitio en Netlify. La función reenvía la cabecera Origin del navegador, así que los envíos desde localhost cuentan como pruebas gratuitas antes de que tu espacio de trabajo tenga plan.
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>
);
}Con React Router en modo framework, el action de la ruta reenvía el envío. El FormData pasa sin cambios, así que bot-field llega a la comprobación del honeypot de Ikiform, y la cabecera Origin del navegador va con él para que los envíos desde localhost cuenten como pruebas. Quita la copia estática que mantenías para 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>
);
}Una página de SvelteKit en Netlify puede enviar directamente desde el navegador, sin una form action propia. El atributo action hace que el formulario funcione antes de la hidratación. Borra cualquier copia prerenderizada del formulario que añadieras para 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}En Nuxt, el componente envía desde el navegador con $fetch, así que no hay ninguna server route que desplegar. $fetch lanza un error ante una respuesta que no sea 2xx, y el envoltorio de error de Ikiform está en el data del error. Quita el formulario estático oculto de public/ y el 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>Una single-page app de React en Netlify enviaba datos urlencoded a / con un campo form-name, y necesitaba una copia estática en index.html para la detección. Ahora envía el FormData al endpoint. Solo cambia la petición:
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(),
});Borra el <form netlify> oculto de index.html cuando ya no envíe nada a Netlify.
El mismo envío desde el navegador en un componente de un solo archivo de Vue. Quita de index.html el formulario oculto que permitía a Netlify detectarlo.
<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 en variables de entorno solo de servidor.Todas las versiones mantienen los mismos nombres de campo que tenía Netlify, así que los emails de notificación y las columnas del CSV te resultarán familiares. La comparativa con Netlify Forms tiene la correspondencia completa de atributos, incluidos data-netlify-recaptcha y los inputs de archivo.
Previews de despliegue y dominios permitidos
Netlify da a cada pull request su propia dirección, como deploy-preview-42--your-site.netlify.app. No es un subdominio de your-site.netlify.app, así que listar la dirección de tu sitio en Allowed domains no cubre las previews, y los envíos desde una preview acaban en spam con origin_mismatch.
- Añade
your-site.netlify.appy tu dominio propio para producción. - Añade un host de preview concreto mientras lo pruebas, y luego quítalo.
- No añadas
netlify.appsolo. Todos los sitios de Netlify podrían enviar a tu formulario. - O apunta las previews a un segundo formulario de Ikiform sin dominios permitidos, mediante una variable de entorno, y mantén estricta la producción.
Haz el cambio sin perder envíos
- Descarga como CSV los envíos pasados de cada formulario desde la sección Forms del sitio. Ikiform no tiene importación.
- Despliega el nuevo formulario en una preview y envía una prueba. Revisa los nombres de campo en el panel de Ikiform.
- Fusiona a producción y envía otra prueba desde el dominio real.
- Sustituye cualquier webhook saliente de Netlify por un webhook de Ikiform. Netlify firma con un token JWS; Ikiform usa las cabeceras de Standard Webhooks, así que tu verificador también cambia.
- Cuando no haya llegado ningún envío a Netlify durante unos días, desactiva la detección de formularios en los ajustes de Forms del sitio.
422 files_not_supported. Si un formulario necesita archivos, deja ese formulario en Netlify Forms y mueve el resto.Comprueba que funciona en Netlify
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Despliega una preview, añade su host y envía el formulario desde ella.
- Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- Confirma que los campos son
email,topicymessage, sinform-namey sinbot-field. - Abre la pestaña Forms del sitio en Netlify y confirma que la prueba no está ahí.
- Envía una vez con
bot-fieldrelleno. Se guarda como spam y no envía nada. - Revisa la bandeja de entrada de cada destinatario de las notificaciones. Cada uno recibe un email con los campos, y Reply va a quien envió el formulario cuando este tiene un campo
email.
Errores comunes
| Problema | Qué hacer |
|---|---|
Aparece un campo form-name en Ikiform | El input oculto form-name sigue en el formulario, o tu código aún lo pone. Quítalo; Ikiform identifica el formulario por la URL. |
| El build de Next.js falla mencionando formularios | Aún hay un atributo netlify o data-netlify en tu código React. Quítalo junto con public/__forms.html. |
| Las previews envían a spam | El host de la preview no está en Allowed domains. Añádelo mientras pruebas, o usa un segundo formulario para las previews. |
| Todos los envíos se marcan como spam, incluso los reales | Honeypot field sigue siendo _gotcha mientras tu input es bot-field, o al revés. Haz que coincidan. |
422 files_not_supported | El formulario tiene un <input type="file"> con un archivo elegido. La subida de archivos aún no está disponible; quita el input o pide un enlace. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
Preguntas
En los planes antiguos, Netlify sube el sitio a Forms Level 1, que incluye 1.000 envíos al mes, y lo cobra al final del ciclo de facturación. En el nivel antiguo gratuito, alcanzar un límite pausa todos los sitios de la cuenta hasta que mejoras el plan.
La documentación de Netlify dice que los envíos de formularios son gratis e ilimitados en los planes por créditos. El problema es el propio sitio: cuando un equipo gratuito se queda sin créditos mensuales, sus proyectos se pausan y no reciben envíos de formularios.
Sí. A Ikiform no le importa dónde esté alojada la página. Quita los atributos de Netlify del formulario, pon su action apuntando a tu endpoint de Ikiform, y Netlify Forms deja de verlo.
No. Esa copia solo existe para que el paso de despliegue de Netlify encuentre el formulario. Ikiform recibe envíos en una URL, así que borra la copia y envía desde tu componente.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.