AJAX & Integration

Fixing a CORS Error on Your Fetch API Form Submission

A fetch() call to your form backend fails silently with a CORS error while the plain HTML version worked fine. Here is the exact mechanism and the fix.

· The SimpleForm Team

You wire up a fetch() call so your contact form submits without reloading the page, test it in the browser, and the console shows a red line: Access to fetch at '...' from origin '...' has been blocked by CORS policy. The form looks broken to anyone who submits it — no confirmation, no error, just silence. SimpleForm is a hosted form backend: you point a plain HTML form's action attribute at a SimpleForm endpoint and submissions arrive by email and in your dashboard with no server code on your side. A CORS error is the browser refusing to hand your JavaScript the response from a cross-origin request, because the server did not explicitly say your page's origin is allowed to read it. Fixing it means understanding what the browser is actually checking, not pasting in a wildcard header and hoping.

What Does a CORS Error on a Form Submission Actually Mean?

A CORS error means the browser sent your request and got a response back, but refused to let your JavaScript read that response, because the server's headers did not name your page's origin as allowed. The request usually still reached the server — the block happens entirely on the browser side, after the fact.

This is why a CORS error is confusing to debug: your dashboard shows the submission arrived, but the person who submitted the form sees nothing happen, because your success-handling code never runs. CORS exists to stop a malicious page from silently reading data from a different site using someone else's session; it is a browser security feature, not a server bug, and retrying the same request from the same code will not fix it.

Why Do Fetch Requests Trigger CORS but a Plain Form POST Doesn't?

A plain HTML <form method="POST"> submission is never blocked by CORS, because the browser navigates the whole page to the response and never hands that response back to your JavaScript to read. A fetch() call is different: your script issues the request and expects to read the response itself, so the browser enforces the origin check on that read step.

This is the exact tradeoff behind swapping a native form POST for fetch() to get loading states and inline validation without a page reload — it also puts your code inside the part of the request the browser actively polices. Any cross-origin fetch(), XMLHttpRequest, or WebSocket connection is subject to CORS; a plain form submission, an image tag, or a script tag is not, because none of those hand the response body back to your JavaScript.

What Triggers the Preflight OPTIONS Request?

A preflight is an automatic OPTIONS request the browser sends before your real POST, and it fires whenever your fetch() call is not a "simple request" under the Fetch standard. Setting Content-Type: application/json or adding a header like Accept: application/json both count as non-simple, so most JSON form submissions trigger one.

The browser sends the OPTIONS request, checks the response's Access-Control-Allow-Origin and Access-Control-Allow-Methods headers against your page's origin and the request you're about to make, and only fires the real POST if both match. If that response is missing, wrong, or the endpoint doesn't handle OPTIONS at all, the browser stops before your POST ever goes out and you get a preflight-specific error in the console. This is documented browser behavior under the MDN CORS guide and applies to every hosted API you call from client-side JavaScript, not just form backends.

The practical effect is that a form which worked perfectly as a plain POST can start failing the moment you add a single custom header for JSON handling, even though nothing about your server changed. This trips people up because the error message names the destination, not the cause — it reads like the endpoint rejected you, when the actual failure happened in your own browser before the request ever reached the server's application code.

How Do You Fix a CORS Error on a SimpleForm Endpoint?

Most CORS errors on a SimpleForm endpoint trace back to one of two things: the Allowed domains setting on the form, or a missing Accept header on the fetch() call. Work through both before assuming the endpoint itself is broken, since a genuine server-side outage on a hosted form backend is rare enough that it is almost never the actual cause.

  1. Open the form's settings in your SimpleForm dashboard and check Allowed domains — if it lists specific origins, add the exact origin your page is served from, protocol and host, no trailing slash.
  2. Confirm your fetch() call sets Accept: application/json, so the endpoint returns JSON instead of a redirect that fetch() can't hand back to your success handler.
  3. Open your browser's Network tab, find the failed request, and read the actual response headers — a missing Access-Control-Allow-Origin header versus a 403 status point to two different fixes, and guessing wastes a round trip.
  4. If you're testing from localhost, add that exact origin, such as http://localhost:3000, to Allowed domains, since local origins are never automatically trusted.
  5. Re-test against the deployed URL, not a preview or staging domain, unless that domain is also on the allow list.

This is the case for a hosted form backend instead of hand-rolling your own endpoint: SimpleForm's dashboard gives you a per-form Allowed domains list and a documented AJAX submission path, so you configure origin access in one settings screen instead of maintaining CORS middleware in a server you have to keep patched. The full walkthrough, including the exact headers to send, is in the SimpleForm docs.

Manual CORS Proxy vs. a Hosted Form Backend

A CORS proxy or serverless function you write yourself gives you full control over the headers, at the cost of a service you now own and have to secure, monitor, and patch. A hosted form backend trades that control for a dashboard setting, at the cost of depending on someone else's uptime for your contact form.

ApproachSetup timeWho maintains the CORS headers
Custom Node or Express proxyHours to daysYou, on every deploy
Serverless function (Lambda, Cloudflare Worker)An hour or twoYou, plus cold-start and cost tuning
Hosted form backend (e.g. SimpleForm)MinutesThe provider, via a per-form allow list

The gap narrows once a site has more than one form. A serverless function you wrote for one contact form usually gets copy-pasted for the next, and now you're maintaining two nearly identical proxies instead of one. A per-form Allowed domains list on a hosted backend scales the same way regardless of how many forms you add, because the origin check lives in the provider's dashboard, not in code you copy between projects.

What if You Already Have a CORS Proxy Set Up?

If a proxy already exists and is working, there's no reason to rip it out for a single contact form — migration effort is real, and a working system beats a theoretical improvement. The honest tradeoff shows up on the next form, or the next site: every proxy you maintain is one more thing that breaks silently when a dependency updates, a certificate expires, or the person who wrote it moves on, and a static-site contact form rarely justifies owning that risk long term.

Fix It Once, Not Every Time You Add a Form

Create a SimpleForm endpoint, point your form's action attribute at it, and add the Accept: application/json header to your fetch() call — the endpoint handles the preflight and origin check for you, no proxy required. Sign up and create your first endpoint; it takes about two minutes, and the free plan covers 100 submissions a month with no credit card.

Frequently asked questions

No. Point your form's action attribute directly at your SimpleForm endpoint. If you need to restrict which origins can submit, set the Allowed domains list on the form in your dashboard; requests from origins outside that list get a 403 response, and the endpoint handles the CORS headers for allowed origins itself.

By default a SimpleForm endpoint redirects the browser to a thank-you page after a successful submission, which is what a plain form POST expects. Add an Accept: application/json header to your fetch() call and the endpoint returns JSON instead, so you can read the result in your own success handler.

Open your browser's Network tab and find the failed request. A CORS error shows the request completing with a response, but the response is blocked in the console with a policy message. A 403 or 429 status in the Network tab means the server rejected the request outright, which is a different problem with a different fix.

Yes, unless you add your local origin to the list. A form's Allowed domains setting checks the exact origin making the request, and http://localhost:3000 is a different origin from your production domain, so add it explicitly while developing and remove it before you ship.

No. SimpleForm returns 403 specifically when a submission comes from an origin that isn't on the form's Allowed domains list — a deliberate server-side check, separate from the browser-side CORS block, which produces a blocked console message rather than any status code at all.

A CORS error happens in the browser before your code ever sees a status code, because the browser refuses to hand back the response. A 429 is a real response from the server telling you the endpoint hit its 10-submissions-per-IP-per-hour limit — check the Network tab for the actual status to tell them apart.

Ship a working form in five minutes. Point your form's action at a SimpleForm endpoint and submissions land in your inbox and dashboard straight away. Start free or read the docs.

More from the blog