A Formspree form is one attribute change. You paste an eight-character endpoint into your form's action and you are collecting submissions:
<form action="https://formspree.io/f/mayzgjbk" method="POST">
<input type="email" name="email" required />
<button type="submit">Send</button>
</form>
No build step, no server, no framework requirement. That is a genuinely good design, and if what you want is a contact form that emails you, this comparison has a short answer: keep using it.
The argument for an alternative is about what arrives at the other end. Formspree's product is an email notification. Everything convenient and everything limiting about it follows from that one fact.
Formspree delivers a form submission as an email, so the submission arrives knowing only the fields you posted. Which page produced it, which campaign, and whether that person already joined your waitlist are all things you have to smuggle in as hidden inputs you maintain by hand. An OperatorStack form posts to /v1/f/{form_key} and the server upserts a contact, links the visitor ID, infers the schema, and writes a form_submission analytics event before it replies. Formspree still wins on file uploads and captcha.
What Formspree Actually Delivers
Formspree's underscore-prefixed fields tell you what the product is built around. They are all email controls:
<form action="https://formspree.io/f/mayzgjbk" method="POST">
<input type="email" name="email" required />
<input type="hidden" name="_subject" value="New demo request" />
<input type="hidden" name="_cc" value="sales@yoursite.com" />
<input type="text" name="_gotcha" style="display:none" />
<button type="submit">Send</button>
</form>
_subject sets the subject line. _cc adds recipients. _gotcha is a honeypot. The naming an older Formspree used for its reply-to field, _replyto, is the clearest tell: the mental model is that you are composing a message, and the form is the compose window.
This is exactly right for a contact form. An inquiry shows up, you hit reply, the conversation continues in the place conversations already happen. Nothing about an analytics pipeline improves that.
It stops being right the moment you want to do something with submissions in aggregate.
The Accept Header That Decides Your UX
The single most common Formspree support question is why a form redirects away from the page. The answer is a request header, and it catches people because it is not the header you would guess.
await fetch("https://formspree.io/f/mayzgjbk", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ email }),
});
Drop that Accept line and Formspree answers with a redirect to its own hosted thank-you page instead of a JSON body. Content-Type describes what you are sending and does not affect the reply. Accept is the one that decides, and a form that works perfectly in your handler will still navigate the user away without it.
Their React package exists largely to make this a non-issue:
import { useForm, ValidationError } from "@formspree/react";
function ContactForm() {
const [state, handleSubmit] = useForm("mayzgjbk");
if (state.succeeded) return <p>Thanks.</p>;
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" />
<ValidationError prefix="Email" field="email" errors={state.errors} />
<button type="submit" disabled={state.submitting}>
Send
</button>
</form>
);
}
Do not plan around a submission cap you read in a blog post, this one included. Form services change their plan limits and their overage behavior, and most comparison posts quote a number that was true when they were written. Open your own billing page and check what happens at the cap, because "submissions stop being delivered" and "submissions queue" are very different launch-week outcomes.
Attribution Is a Hidden Field You Maintain
Formspree records the fields you post. That is the whole data model, and it means attribution is your job:
<input type="hidden" name="page" />
<input type="hidden" name="utm_source" />
<input type="hidden" name="referrer" />
const params = new URLSearchParams(location.search);
form.page.value = location.pathname;
form.utm_source.value = params.get("utm_source") || "";
form.referrer.value = document.referrer;
This works. It also has to be repeated on every form, kept in sync when you add a campaign parameter, and it captures only the visit that submitted. A person who read three blog posts last week, came back through a newsletter link today, and then filled the form arrives as utm_source: newsletter with no trace of the three posts that actually did the work.
The deeper problem is that the values are strings in an email. There is nothing on the other side to join them to, because Formspree never saw the page views.
Where an OperatorStack Submission Goes
The markup is comparable. You create a form in the dashboard, get a key like frm_abc123, and point 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 endpoint takes URL-encoded, JSON, or multipart bodies, so there is no Accept header to remember. _redirect answers with a 303 and the browser lands on your own thank-you page. Underscore-prefixed fields are treated as instructions and stripped before anything is stored.
Or through the SDK, if the script tag is already on the page:
await OperatorStack.submitForm("frm_abc123", {
email: "founder@example.com",
company: "Acme",
});
That call is the one that matters here, because of what it adds on the way out. submitForm attaches visitor_id: getOrCreateVisitorId() to the body before sending, and that is the same visitor ID your page views were already recorded under. The submission arrives attached to the session that produced it, with no hidden inputs involved.
Then three things happen server-side before you get a response:
A contact is created or updated. If the payload has 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 person who filled your research form and your waitlist form is one contact record, not two rows in two places.
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 anywhere.
An analytics event is written. A form_submission event lands on the same timeline as your page views, carrying the same visitor ID. "Which page did people fill this form from" becomes a question the data answers on its own, rather than one you prepared for in advance.
You still get the email. It is a notification about a record, rather than the record itself.
Side by Side
| Formspree | OperatorStack | |
|---|---|---|
| Setup | Paste an endpoint into action | Paste a form key into action |
| Primary destination | Your inbox | A contact record, plus an email notification |
| AJAX response format | Decided by the Accept header | JSON by default, _redirect for a 303 |
| Accepted bodies | URL-encoded, JSON, multipart | URL-encoded, JSON, multipart |
| Attribution | Hidden inputs you fill in and maintain | Visitor ID attached by the SDK |
| Adding a field | Post it, it appears in the email | Post it, it becomes a filterable column |
| Creates a contact record | No | Yes, on any payload with an email |
| Submission linked to visitor | No | Yes, via the shared visitor ID |
| Spam filtering | Honeypot, plus reCAPTCHA or hCaptcha | Honeypot, timing gate, 100 req/min per IP |
| File uploads | Yes, on paid plans | No, non-string fields are dropped |
| React support | @formspree/react with a useForm hook | Plain SDK call, no framework package |
When Formspree Is the Right Call
Three cases, and none of them are 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. If your form takes a resume, a portfolio PDF, or a bug-report screenshot, the comparison ends here.
You want a real captcha. A honeypot and a rate limit stop opportunistic bots. They do not stop someone who has looked at your form. Formspree's reCAPTCHA and hCaptcha support is a better out-of-the-box answer if your form is being targeted rather than scraped.
The form is a contact form and the job is replying. If every submission ends in you writing back, the inbox is the correct destination and a contact record is overhead. Adding a tool to replace a working mailto replacement is a downgrade.
Most founders end up splitting rather than switching. Formspree stays on the careers page and the upload form. OperatorStack goes on the signup and research forms, where the answers need to join a contact list. A script tag and a form action URL do not conflict on the same page.
The Actual Decision
Ask what you want to do with a submission a week after it arrives.
If the answer is "reply to it," Formspree already does that with less setup than anything else, and the Accept header is the only real gotcha. If the answer contains the word "which" (which page, which campaign, which of these people also joined the waitlist), then the submission needs to be joined to something. An email has nothing to join to, and no amount of hidden inputs fixes that, because the thing you want to join to is traffic Formspree never saw.
Frequently Asked Questions
Why does my Formspree form redirect to a Formspree page instead of staying put?
The request did not carry an Accept: application/json header. Formspree picks its response format from that header: with it you get a JSON body your page can handle, without it you get a redirect to Formspree's hosted thank-you page. Setting Content-Type to JSON is not enough, Accept is the one that decides.
Can I see which landing page a Formspree submission came from?
Only if you put it there yourself. Formspree records the fields you post and nothing else, so page, campaign, and referrer have to be hidden inputs your own code fills in. The attribution is only as good as your discipline about keeping those inputs on every form.
Does OperatorStack accept file uploads like Formspree does?
No. The public endpoint parses multipart bodies but keeps only the string fields, so an attached file is dropped rather than stored. Formspree handles uploads on its paid plans, and that is a real reason to stay.
Do I need JavaScript on the page for an OperatorStack form?
No. A plain HTML form with method="POST" and an action pointing at your form key works, and a _redirect field sends the browser to your own thank-you page with a 303. Without the script tag there is no visitor ID on the submission, so it still creates a contact but will not join to an analytics session.
Is the form key in the action URL a secret?
No, on either tool. Formspree's endpoint hash and OperatorStack's frm_... key are both public by design, so that plain HTML forms can post to them. The key 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.