On April 14, 2026, Netlify changed what a form submission costs. The changelog is one line: "Now form submissions are free across all Credit plans. Previously, each form submission cost 1 credit." Most of the "Netlify Forms is getting expensive" posts you can find were written before that.
So this comparison does not get to be about price, the same way the Tally comparison does not. If you deploy on Netlify and you want a contact form that emails you, Netlify Forms is two HTML attributes and costs nothing, and you should use it.
The argument for an alternative is about who creates the form endpoint. With Netlify, you do not. Your build does, by reading your HTML at deploy time, and everything awkward about Netlify Forms follows from that one fact.
Netlify Forms costs nothing now, so the reason to look elsewhere is architectural. The endpoint is created by Netlify's build system parsing your deployed HTML, which means client-rendered forms need a hidden duplicate form you maintain by hand, the form cannot exist anywhere but a Netlify deploy, and submissions land in a dashboard that has never seen your visitor. OperatorStack forms post to a hosted endpoint from your own page and arrive already joined to the visitor, the contact record, and the analytics session.
Your Build Creates the Form, Not You
Turning on a Netlify form is one attribute:
<form name="contact" method="POST" data-netlify="true">
<input name="email" type="email" />
<button>Send</button>
</form>
At deploy time, Netlify's post-processing parses the static HTML it is about to serve, finds forms carrying data-netlify="true" (or the bare netlify attribute), strips that attribute out, and injects a hidden field:
<input type="hidden" name="form-name" value="contact" />
That injected field is what routes the submission. The name on the <form> becomes the form's identity in the Netlify UI, and two forms with the same name are the same form.
This is genuinely elegant for a hand-written HTML page. It is also the source of every Netlify Forms problem people actually hit, because a parser that reads your built HTML can only see forms that exist in your built HTML.
The Hidden Duplicate Form
If your form is rendered by React, Vue, Svelte, or any client-side framework, the parser sees nothing at deploy time. No endpoint gets created. The form renders fine in the browser, the submit handler fires, and the POST lands on a route that does not handle it.
Netlify's documented fix is to ship a second, hidden copy of the form in your static HTML:
<form name="pizzaOrder" data-netlify="true" style="display:none;">
<input name="order" type="text" />
<input name="form-name" type="hidden" value="pizzaOrder" />
</form>
Then your real component has to include the form-name field itself:
<form name="pizzaOrder" method="post" onSubmit={handleSubmit}>
<input type="hidden" name="form-name" value="pizzaOrder" />
<input name="order" type="text" onChange={handleChange} />
<input type="submit" />
</form>
Two declarations of the same form, in two files, in two languages, that have to agree on the form name and on every field name.
The failure mode is silent. Add a company field to your React component and forget the hidden form, and submissions keep succeeding. The company value is simply not among the fields Netlify recorded, because that field was never registered. You find out when you export the CSV and a column you expected is missing.
The AJAX Body Is Not JSON
The other thing that costs people an afternoon: Netlify's form endpoint takes URL-encoded bodies, not JSON. The documented submit handler is this shape, and the encoding is not optional:
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(formData).toString(),
});
};
Note the target: "/", your own site root. That is the whole architecture in one line. There is no form service URL, because the endpoint is your deployment. Netlify's edge intercepts the POST before your app sees it.
Which answers the portability question before you ask it. Move the site to Vercel or Cloudflare Pages and that fetch("/") starts posting into your own app, which has no idea what to do with it.
Where the Submission Goes
An OperatorStack form has no build step in it. You create a form in the dashboard, get a key like frm_abc123, and point markup at the endpoint:
<form method="POST" action="https://api.operatorstack.dev/v1/f/frm_abc123">
<input name="email" type="email" required />
<input name="company" type="text" />
<input type="hidden" name="_redirect" value="https://yoursite.com/thanks" />
<button>Send</button>
</form>
The _redirect field is the no-JavaScript path: the endpoint answers with a 303 and the browser lands on your own thank-you page. Underscore-prefixed fields are treated as instructions and stripped from the stored data.
Or through the SDK, if the script tag is already on the page:
await OperatorStack.submitForm("frm_abc123", {
email: "founder@example.com",
company: "Acme",
});
The SDK call is the version that matters for this comparison, because of what it adds on the way out. submitForm attaches visitor_id: getOrCreateVisitorId() to the body before sending. That is the same visitor ID your page views were recorded under, so the submission arrives already attached to the session that produced it.
On the server, three things happen to that submission that do not happen on Netlify:
A contact is created or updated. If the payload contains an email, the endpoint upserts a unified contact with source: "form" and source_detail set to the form's name, then links the visitor ID to it. The same person filling your waitlist form and your research form is one contact record, not two rows in two dashboards.
The schema updates itself. Fields are inferred from what you actually sent. Add company to your markup and it becomes a filterable column on the next submission, with no schema edit and no hidden duplicate form to keep in sync.
An analytics event is written. A form_submission event lands on the same timeline as your page views, carrying the same visitor ID, so "which page did people fill this form from" is a question the data can answer.
Netlify's submissions are a good list of submissions. They are just a list that has never seen your traffic, your referral codes, or your waitlist.
Side by Side
| Netlify Forms | OperatorStack | |
|---|---|---|
| Cost per submission | Free on Credit plans since 2026-04-14 | Free tier, then plan limits |
| How the endpoint is created | Build-time HTML parsing | Created in the dashboard, returns a frm_... key |
| Client-rendered forms | Hidden duplicate form, maintained by hand | Same endpoint, no duplicate |
| Works off your host | No, the endpoint is your Netlify deploy | Yes, any host or none |
| AJAX body | URL-encoded only | JSON, URL-encoded, or multipart |
| Spam filtering | Akismet by default, plus honeypot and reCAPTCHA | Honeypot, timing gate, per-IP rate limit |
| File uploads | Yes, 8 MB request cap, 30s timeout | No, non-string fields are dropped |
| Submission linked to visitor | No | Yes, via the shared visitor ID |
| Creates a contact record | No | Yes, on any payload with an email |
When Netlify Forms Is the Right Call
Three cases, and they are not edge cases.
You need file uploads. OperatorStack's endpoint parses multipart bodies but keeps only the string values, so a file is dropped rather than stored. Netlify takes the upload, up to 8 MB per request with a 30-second timeout and one file per input field. If your form collects a resume or a screenshot, this comparison is over.
You want spam handled for you. Every Netlify submission runs through Akismet before it reaches your verified list, and you can add a honeypot with one attribute, netlify-honeypot="bot-field", or a reCAPTCHA with data-netlify-recaptcha="true". That is a better out-of-the-box spam story than a honeypot and a rate limit.
Your site is hand-written HTML on Netlify and the form emails you. Two attributes, no script tag, no account anywhere else. Adding a tool to replace that is a downgrade.
The split most founders end up with is not one tool. Keep Netlify Forms for the careers page and the upload form, and put OperatorStack on the signup and research forms, where knowing which landing page produced the submission is the point. One script tag does not conflict with a data-netlify attribute on the same site.
The Actual Decision
Ask what you want to do with a submission a week after it arrives.
If the answer is "read it" or "reply to it," Netlify Forms already does that and costs nothing. If the answer involves the word "which" ("which campaign", "which page", "which of these people also joined the waitlist"), you need the submission joined to something, and Netlify's form store has nothing to join it to.
Frequently Asked Questions
Do I have to pick one for the whole site?
No. Netlify Forms is an HTML attribute and OperatorStack is a script tag, so they can run on the same page without interfering. Route each form to whichever one fits what you plan to do with the answers.
What happens to my existing Netlify submissions if I switch?
Export them from the Netlify UI as CSV first. Netlify keeps submissions per form in its own store, and there is no migration path into another tool, so the CSV is the handoff. Imported contacts arrive without a visitor ID, which means historical submissions will not join to analytics sessions even after you switch.
Is the form key in the action URL a secret?
No. The frm_... key is public by design so plain HTML forms can post to it. It grants nothing except the ability to submit to that one form. The protection is the rate limit, the honeypot, and the per-form enabled toggle.
Can I keep using a plain HTML form with no JavaScript?
Yes, on both. Netlify wants the data-netlify attribute and OperatorStack wants a method="POST" and an action pointing at your form key. The tradeoff is that without the script tag, an OperatorStack submission has no visitor ID attached, so it still creates a contact but will not join to an analytics session.
Does the build-time parsing affect Astro or Next.js too?
It affects anything whose forms are not in the built HTML. A static Astro page with a form in the markup is parsed fine. A Next.js page that renders its form on the client is not, and needs the same hidden duplicate form as a React SPA.