Outgrow builder forms7 min read
Netlify Forms limit: what happens at 100 submissions, and the way out
Legacy Netlify plans meter form submissions per site and upgrade you automatically, and credit plans can pause the site, so move the form to a backend.
On Netlify's legacy plans, each site includes 100 form submissions a month, and passing that upgrades the site to a paid Forms level automatically. On the credit-based plans for accounts created since September 2025, forms are free, but a free team that runs out of credits has every site paused, forms included. Moving the form to Ikiform means removing the Netlify attributes and setting the action, in plain HTML or any framework.
What's different on Netlify
Netlify isn't a site builder; it's a host with forms built in. That shapes both the limit and the way out. Which limit applies depends on when your account was created:
| Account | What Netlify's docs say | What it means for a busy form |
|---|---|---|
| Legacy plans, created before 4 September 2025 | Forms Level 0 includes 100 submissions and 10 MB of uploads per site per month. Passing it upgrades the site to Forms Level 1, with 1,000 submissions, charged at the end of the billing cycle. Past that, extra usage packages add 500 at a time | A spam wave can move a site to a paid level without anyone deciding to. On the free legacy tier, reaching a limit pauses every site on the account |
| Credit-based plans, created since then | Form submissions are free and unlimited, with no levels | The form itself costs nothing. But when a free team uses its monthly credits, Netlify pauses all its projects, and paused projects don't receive form submissions |
Netlify's own docs are clear that the legacy upgrade is automatic and that you can't move back down while usage is above the lower level. Check your team's plan type and the site's form usage in the Netlify dashboard before deciding anything.
The second reason people move has nothing to do with numbers. Netlify Forms works by reading your deployed HTML:
- Forms are found at deploy time. A form rendered by React, Vue or Svelte doesn't exist in that HTML, so you keep a hidden static copy for the build to find, and post to it with a
form-namefield. - With Next.js, the OpenNext adapter needs that copy as a static file in
public/, and the post must go to that static file, not to a Next.js page. The build fails ifdata-netlifyappears in React code without one. - Submissions must be urlencoded or multipart. JSON isn't accepted.
- The form only works while the site is deployed on Netlify. Moving hosts means moving forms.
- On a legacy plan, a site starts the month on Forms Level 0 with 100 submissions included.
- Submission 101 arrives. Netlify's usage docs describe the site's usage, so a burst of spam can be what pushes it over.
- Netlify upgrades the site to Forms Level 1, with 1,000 submissions, and bills it at the end of the cycle.
- Past Level 1, extra usage packages add 500 submissions at a time.
- With the form posting to Ikiform, the page still lives on Netlify but the post goes to the Ikiform endpoint.
- Netlify Forms never receives the post, so there's nothing for it to count or upgrade.
Move the form, stack by stack
Before you change any code, create one Ikiform form per Netlify form name and set its Honeypot field to bot-field. Then your existing hidden input keeps working as the honeypot, with no rename. Pick your stack:
Plain HTML on Netlify needs the least change. Delete the Netlify attributes and the form-name input, point action at the endpoint, and move the thank-you path into _redirect as a full URL. The bot-field input stays as your 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>On Netlify, a Next.js form only worked with Netlify Forms through a static copy in public/, such as public/__forms.html, that your component posted to. Delete that file, remove any NETLIFY_NEXT_VERIFY_FORMS setting, and post from the component to Ikiform. The form also posts without JavaScript, thanks to its 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>
);
}A static Astro page needs no JavaScript at all. Remove data-netlify from the form, set the endpoint as the action, and build the redirect from site in astro.config.mjs. The page can stay prerendered.
---
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>A TanStack Start app on Netlify renders forms in React, which Netlify's deploy-time detection couldn't see. Post through a server function instead, and drop the hidden static copy. Set IKIFORM_SECRET_KEY in the site's environment variables in Netlify. The function passes on the browser's Origin header, so posts from localhost count as free tests before your workspace has a 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>
);
}With React Router in framework mode, the route's action forwards the post. The FormData passes through unchanged, so bot-field reaches Ikiform's honeypot check, and the browser's Origin header goes along so localhost posts count as tests. Remove the static copy you kept for 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>
);
}A SvelteKit page on Netlify can post straight from the browser, with no form action of your own. The action attribute keeps the form working before hydration. Delete any prerendered copy of the form you added for 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}In Nuxt the component posts from the browser with $fetch, so there's no server route to deploy. $fetch throws on a non-2xx answer, and Ikiform's error envelope is on the error's data. Remove the hidden static form from public/ and the form-name field.
<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>A React single-page app on Netlify posted urlencoded data to / with a form-name field, and needed a static copy in index.html for detection. Now it posts the FormData to the endpoint. Only the request changes:
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(),
});Delete the hidden <form netlify> from index.html once nothing posts to Netlify.
The same browser-side post in a Vue single-file component. Remove the hidden form from index.html that let Netlify detect it.
<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 in server-only environment variables.Every version keeps the same field names Netlify had, so notification emails and CSV columns look familiar. The Netlify Forms comparison has the full attribute mapping, including data-netlify-recaptcha and file inputs.
Deploy previews and allowed domains
Netlify gives every pull request its own address, such as deploy-preview-42--your-site.netlify.app. That isn't a subdomain of your-site.netlify.app, so listing your site's address under Allowed domains doesn't cover previews, and preview posts land in spam with origin_mismatch.
- Add
your-site.netlify.appand your custom domain for production. - Add a specific preview host while you test it, then remove it.
- Don't add
netlify.appon its own. Every site on Netlify would be allowed to post to your form. - Or point previews at a second Ikiform form with no allowed domains, through an environment variable, and keep production strict.
Cut over without losing submissions
- Download each form's past submissions as CSV from the site's Forms section. Ikiform has no import.
- Deploy the new form to a preview and send a test. Check field names in the Ikiform dashboard.
- Merge to production and send another test from the live domain.
- Replace any Netlify outgoing webhook with an Ikiform webhook. Netlify signs with a JWS token; Ikiform uses Standard Webhooks headers, so your verifier changes too.
- Once no post has reached Netlify for a few days, turn off form detection in the site's Forms settings.
422 files_not_supported. If a form needs files, keep that one form on Netlify Forms and move the rest.Check it works on Netlify
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Deploy a preview, add its host, and submit the form from it.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- Confirm the fields are
email,topicandmessage, with noform-nameand nobot-field. - Open the site's Forms tab in Netlify and confirm the test isn't there.
- Post once with
bot-fieldfilled in. It's stored as spam and sends nothing. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield.
Gotchas
| Problem | What to do |
|---|---|
A form-name field shows up in Ikiform | The hidden form-name input is still in the form, or your code still sets it. Remove it; Ikiform identifies the form by the URL. |
| The Next.js build fails mentioning forms | A netlify or data-netlify attribute is still in your React code. Remove it along with public/__forms.html. |
| Previews post to spam | The preview host isn't on Allowed domains. Add it while you test, or use a second form for previews. |
| Every post is marked spam, even real ones | Honeypot field is still _gotcha while your input is bot-field, or the reverse. Make them match. |
422 files_not_supported | The form has an <input type="file"> with a file chosen. File uploads aren't supported yet; remove the input or ask for a link instead. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
Questions
On legacy plans, Netlify upgrades the site to Forms Level 1, which includes 1,000 submissions a month, and charges it at the end of the billing cycle. On the free legacy tier, reaching a limit pauses every site on the account until you upgrade.
Netlify's docs say form submissions are free and unlimited on credit-based plans. The catch is the site itself: when a free team runs out of monthly credits, its projects are paused and don't receive form submissions.
Yes. Ikiform doesn't care where the page is hosted. Remove the Netlify attributes from the form, set its action to your Ikiform endpoint, and Netlify Forms stops seeing it.
No. That copy only exists so Netlify's deploy step can find the form. Ikiform takes posts at a URL, so delete the copy and post from your component.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.