# 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.

Published 2026-10-25 · https://ikiform.com/guides/website-builder-forms/netlify-forms-limit

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-name` field.
- 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 if `data-netlify` appears 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.

Diagram: How a legacy Netlify site's form usage climbs in a month, and what posting to Ikiform changes

1. On a legacy plan, a site starts the month on Forms Level 0 with 100 submissions included.
2. Submission 101 arrives. Netlify's usage docs describe the site's usage, so a burst of spam can be what pushes it over.
3. Netlify upgrades the site to Forms Level 1, with 1,000 submissions, and bills it at the end of the cycle.
4. Past Level 1, extra usage packages add 500 submissions at a time.
5. With the form posting to Ikiform, the page still lives on Netlify but the post goes to the Ikiform endpoint.
6. Netlify Forms never receives the post, so there's nothing for it to count or upgrade.

The hosting stays on Netlify. Only the form's destination moves, and with it the metering.

## 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:

**HTML**

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.

**Before** (`contact.html`)

```html
<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>
```

**After** (`contact.html`)

```html
<form method="post" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx">
  <input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
  <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>
  <input type="hidden" name="_redirect" value="https://your-site.netlify.app/thanks" />
  <button type="submit">Send</button>
</form>
```

**Next.js**

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`.

`app/contact/contact-form.tsx`:

```tsx
"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>
  );
}
```

**Astro**

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.

`src/pages/contact.astro`:

```astro
---
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>
```

**TanStack Start**

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.

`src/routes/contact.tsx`:

```tsx
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>
  );
}
```

**React Router**

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.

`app/routes/contact.tsx`:

```tsx
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>
  );
}
```

**SvelteKit**

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.

`src/routes/contact/+page.svelte`:

```svelte
<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}
```

**Nuxt**

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.

`app/components/ContactForm.vue`:

```vue
<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>
```

**React**

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:

**Before** (`ContactForm.tsx`)

```tsx
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(),
});
```

**After** (`ContactForm.tsx`)

```tsx
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new FormData(event.currentTarget),
});
const result = await response.json();
if (!result.ok) setError(result.error.message);
```

Delete the hidden `<form netlify>` from `index.html` once nothing posts to Netlify.

**Vue**

The same browser-side post in a Vue single-file component. Remove the hidden form from `index.html` that let Netlify detect it.

`src/components/ContactForm.vue`:

```vue
<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>
```

> The TanStack Start and React Router versions post from your server. The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `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](https://ikiform.com/alternatives/netlify-forms) 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.app` and your custom domain for production.
- Add a specific preview host while you test it, then remove it.
- Don't add `netlify.app` on 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

1. Download each form's past submissions as CSV from the site's **Forms** section. Ikiform has no import.
2. Deploy the new form to a preview and send a test. Check field names in the Ikiform dashboard.
3. Merge to production and send another test from the live domain.
4. Replace any Netlify outgoing webhook with an Ikiform [webhook](https://ikiform.com/docs/webhooks.md). Netlify signs with a JWS token; Ikiform uses Standard Webhooks headers, so your verifier changes too.
5. Once no post has reached Netlify for a few days, turn off form detection in the site's Forms settings.

> Netlify accepts file uploads and runs Akismet on every submission. File uploads aren't supported yet. A post with a file gets `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

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Deploy a preview, add its host, and submit the form from it.
3. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
4. Confirm the fields are `email`, `topic` and `message`, with no `form-name` and no `bot-field`.
5. Open the site's **Forms** tab in Netlify and confirm the test isn't there.
6. Post once with `bot-field` filled in. It's stored as spam and sends nothing.
7. 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 `email` field.

## 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

### What happens when a Netlify site passes 100 form submissions?

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.

### Are Netlify Forms free on the new credit-based plans?

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.

### Can I keep hosting on Netlify and use Ikiform for forms?

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.

### Do I still need a hidden static form for React or Next.js?

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.

### Is Ikiform free?

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.

## More guides

- [Outgrow builder forms](https://ikiform.com/guides/website-builder-forms.md): When Webflow, Netlify, Carrd or Framer forms hit a cap or a missing feature, keep the form you designed and point it at a form backend instead.
- [Webflow submission limit](https://ikiform.com/guides/website-builder-forms/webflow-submission-limit.md): Webflow's free Starter plan stops collecting after 50 form submissions per site and exported sites get none, so point the Form Block at Ikiform instead.
- [Framer forms](https://ikiform.com/guides/website-builder-forms/framer-forms.md): Framer forms already send to email, Sheets or a webhook; add Ikiform as the webhook for stored, filtered submissions, team alerts and more destinations.
- [Carrd forms](https://ikiform.com/guides/website-builder-forms/carrd-forms.md): Carrd's contact form only emails you and custom forms need Pro Plus, so use Send to URL on Plus or an HTML embed on Standard to store every submission.
