splitforms.com
← Back to the journal

Fix CORS Errors on Form Submission (2026 Guide)

Why your fetch form POST throws a CORS error and how to fix it — preflight, headers, and a CORS-ready form endpoint you can POST to from any origin.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5 per month.
Short answer
A CORS error on form submission has one of three causes: (1) you're POSTing JSON, which triggers a preflight your server doesn't handle; (2) the origin isn't whitelisted on the backend; (3) you're intercepting the submit with fetch when a plain <form action> wouldn't trigger CORS at all. Fix: switch to a URL-encoded body, whitelist the origin, or drop the JS intercept.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

What CORS actually checks (and what it doesn't)

CORS — Cross-Origin Resource Sharing — is a browser-side security policy that decides whether JavaScript on origin A is allowed to read responses from origin B. It does not exist server-side. It does not exist outside the browser. curl never sees a CORS error. Mobile apps never see a CORS error. The browser invented the rule and the browser enforces it.

The piece most CORS-error guides skip: a plain HTML form submission is not subject to CORS. When the browser navigates to a new URL because a form's default submit handler ran, it doesn't evaluate Access-Control headers. That's why <form action="https://splitforms.com/api/submit"> works on every site without configuring anything. CORS only kicks in when JavaScript tries to read the response, which means: fetch(), XMLHttpRequest, the Beacon API. If you're seeing a CORS error during a form submission, check whether you're intercepting the submit with JavaScript first.

How to read the actual error

Modern Chromium prints the most useful CORS errors in the Network tab, not the Console. The console message is short:

Access to fetch at 'https://splitforms.com/api/submit' from origin
'https://example.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The three pieces that matter: the failing URL (the request the browser tried to make), the origin (the page making the request), and the specific reason. The most common reasons:

  • "Response to preflight request doesn't pass access control check." The OPTIONS request returned without the right headers. The real POST never went out.
  • "The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'." You're sending credentials (a cookie, an Authorization header) and the server returned *. Replace the wildcard with the explicit origin.
  • "The 'Access-Control-Allow-Headers' header doesn't allow the request header." You're sending a custom header (X-Form-Token, Content-Type: application/json) and the server hasn't opted into accepting it.
  • "Origin is not allowed." The server explicitly returned an Access-Control-Allow-Origin that doesn't match your origin. The fix is on the server.

Access to fetch at '…' from origin '…' has been blocked by CORS policy

This console message means the API's response, or its answer to the OPTIONS preflight, didn't carry an Access-Control-Allow-Origin header matching your page's origin, so the browser hid the response from your script. The text after CORS policy: names the failing check. Chrome prints one of two endings depending on whether the request was preflighted:

The CORS console message, word for word
Browser and sourceExact message
Chrome 154JSON POST (preflighted)Tested October 2, 2026Access to fetch at 'https://api.example.com/leads' from origin 'https://yoursite.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Chrome 154FormData POST (no preflight)Tested October 2, 2026Access to fetch at 'https://api.example.com/leads' from origin 'https://yoursite.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
FirefoxMDN's template with the missing-header reasonMDN: CORS errorsCross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/leads. (Reason: CORS header 'Access-Control-Allow-Origin' missing).
  • "Response to preflight request doesn't pass access control check": a JSON body or custom header triggered an OPTIONS request the server didn't answer correctly. Send a simple body instead (Fix 1) or handle OPTIONS on the server (Fix 4).
  • "No 'Access-Control-Allow-Origin' header is present on the requested resource" with no preflight mention: the POST went out and probably reached the server, but your script can't read the answer. Allow your origin (Fix 2) or drop the JavaScript intercept (Fix 3).

Safari's console wording wasn't captured in our test; what your script receives there is TypeError: Load failed, covered next.

TypeError: Failed to fetch (Chrome), Load failed (Safari), NetworkError when attempting to fetch resource (Firefox)

This is the error your catch block receives whenever fetch() gets no readable response, and it's the same error for a CORS block, a server that's down, a typo in the URL or a dropped connection. Browsers hide the reason from scripts on purpose, so the message alone can't tell you which one happened:

What catch (err) receives, word for word
Browser and sourceExact message
Chrome 154Tested October 2, 2026TypeError: Failed to fetch
Safari 26.6.2Tested October 2, 2026TypeError: Load failed
FirefoxFirefox source: dom/bindings/Errors.msgTypeError: NetworkError when attempting to fetch resource.
  1. Check the console for a line containing blocked by CORS policy (Chrome) or Cross-Origin Request Blocked (Firefox). If it's there, it's CORS: see the section above.
  2. Check the Network tab. A request marked failed with no status code means it never got an answer: wrong host, DNS, offline, or a blocked mixed-content (HTTP from an HTTPS page) request.
  3. Run the same request with curl. If curl gets a normal response, the server is fine and the browser is enforcing CORS; if curl fails too, fix the URL or the server first.

A fetch() that gets any HTTP status back, even 404 or 500, doesn't throw this error; it resolves with res.ok === false. For the full submit-handler pattern, see how to send form data to an API.

Tested in Chrome 154.0.8037.97 and Safari 26.6.2 on macOS 26.6.2, on . Firefox wasn't part of that run, so Firefox wording is quoted from Firefox's own source or MDN, linked in each row.

Diagnose: which kind of request am I making?

Open the Network tab, submit the form, and look at the failing request. Three indicators tell you what kind of request the browser thinks it is.

  • If you see an OPTIONS request followed by a POST, the browser is making a preflight check. That happens when the request has a non-simple content type (e.g. application/json) or non-simple headers (e.g. Authorization: Bearer ...).
  • If you see only a POST with red text in the Status column, the request is "simple" (URL-encoded body, no custom headers) but the response was rejected on the basis of its CORS headers — usually Access-Control-Allow-Origin doesn't match your origin.
  • If you see no request at all, a Content Security Policy or extension blocked the request before the browser tried to make it. CSP errors look similar in the Console but mention connect-src.
# Reproduce the request from the terminal — without browser CORS
curl -i -X OPTIONS https://splitforms.com/api/submit \
  -H "Origin: https://example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# Look for these headers in the response:
# Access-Control-Allow-Origin: https://example.com
# Access-Control-Allow-Methods: POST, OPTIONS
# Access-Control-Allow-Headers: content-type
# Access-Control-Max-Age: 86400

If the curl preflight returns the right headers but the browser still errors, the problem is on your side (different origin sent, custom headers, etc.). If the curl preflight is missing headers or returns 405, the problem is server-side.

Fix 1: switch from JSON to URL-encoded body

The fastest CORS-error fix on a form is to drop the Content-Type: application/json and use either URLSearchParams or FormData as the body. Both are "simple" content types under the CORS spec, so the browser skips the OPTIONS preflight entirely.

// ❌ This triggers preflight — and many backends don't handle OPTIONS.
fetch('https://splitforms.com/api/submit', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ access_key: KEY, name, email, message }),
});

// ✅ This is a "simple" request. No preflight. No CORS error.
const params = new URLSearchParams({
  access_key: KEY,
  name, email, message,
});
fetch('https://splitforms.com/api/submit', {
  method: 'POST',
  body: params,
  headers: { Accept: 'application/json' },
});

// ✅ Also simple. Use this when the form has file uploads.
const formEl = document.querySelector('form')!;
fetch(formEl.action, {
  method: 'POST',
  body: new FormData(formEl),
  headers: { Accept: 'application/json' },
});

The splitforms backend accepts JSON and URL-encoded equally — there's no functional reason to use JSON for a form submission. Form data is the encoding the web was built around; switching to it removes an entire class of CORS errors and is what every example in our docs uses. See the HTML form action guide for the full coverage.

Fix 2: add your origin to the backend's allow-list

Hosted form backends (splitforms, Formspree, Web3Forms) lock submissions to a configured list of origins to prevent rebranded spam. The error you see when your domain isn't in the list looks identical to a missing-CORS-headers error, but the cause is different.

On splitforms, the access key's allowed-origins list lives in the dashboard. Add the production domain, the preview pattern, and http://localhost:3000 for local dev. Wildcards work on Pro plans (*.vercel.app, *.netlify.app). Save and the error disappears within seconds — the change propagates immediately, no deploy needed.

  • Open the splitforms dashboard, select the form, click "Allowed origins".
  • Add https://yourdomain.com, https://*.vercel.app (Pro), and http://localhost:3000.
  • Submit a test form to confirm.

Configuration walkthrough at /docs.

Fix 3: remove the JS intercept (when you don't need it)

If your form is just a contact form and you don't need a custom success state, the cleanest fix is to remove the fetch() and let the browser submit the form natively. The browser POSTs the form, splitforms returns a redirect to your thank-you page, the browser follows it. No CORS error because there's no JavaScript reading the response.

<!-- ❌ Form intercepted by JS — fetch hits CORS rules -->
<form id="contact" action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_KEY">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>
<script>
  document.getElementById('contact').addEventListener('submit', async (e) => {
    e.preventDefault(); // <- this is what triggers CORS
    await fetch(...); // ...
  });
</script>

<!-- ✅ Native form submit — no CORS rules apply -->
<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_KEY">
  <input type="hidden" name="redirect" value="https://yoursite.com/thanks">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

The redirect hidden input tells splitforms where to send the visitor after a successful submission. The browser navigates there, the page reloads with whatever thank-you UI you want. Less code, no CORS complications, works for visitors with JavaScript disabled. This is the right pattern unless you specifically need an in-place success state without a page reload.

Fix 4: configure CORS on your own backend

When the backend is yours (not a hosted form service), the fix is to set the right response headers. The patterns by stack:

// Express + cors middleware
import express from 'express';
import cors from 'cors';

const app = express();
app.use(cors({
  origin: ['https://yoursite.com', 'http://localhost:3000'],
  methods: ['POST', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true, // only if you actually send cookies
}));

app.post('/api/contact', express.urlencoded({ extended: true }), (req, res) => {
  // ... handle the form submission
  res.json({ ok: true });
});
// Next.js 15 route handler
import { NextResponse } from 'next/server';

const ALLOW_ORIGIN = 'https://yoursite.com';

export async function OPTIONS() {
  return new NextResponse(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': ALLOW_ORIGIN,
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
      'Access-Control-Max-Age': '86400',
    },
  });
}

export async function POST(req: Request) {
  const data = await req.formData();
  // ... handle
  return new NextResponse(JSON.stringify({ ok: true }), {
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': ALLOW_ORIGIN,
    },
  });
}

Two things every example needs to get right: (1) the OPTIONS handler must return the same allow-origin as the POST; (2) Access-Control-Max-Age caches the preflight result so subsequent submits don't re-check. 86400 seconds (24h) is a safe default. If you'd rather skip building the CORS middleware yourself, point the form at the splitforms HTML form backend and let it handle origin validation.

Common traps that look like CORS but aren't

  • Mixed content. An HTTPS page submitting to an HTTP backend is blocked by the browser before CORS even runs. The console says "Mixed Content," not CORS.
  • Content Security Policy. A CSP connect-src directive that doesn't include the form backend blocks the request. The console error mentions CSP, not CORS.
  • Browser extensions. Privacy extensions (Privacy Badger, uBlock Origin) sometimes block submissions to known form backends. Test in incognito with extensions disabled.
  • Network proxy. Corporate proxies that strip CORS response headers can make a working backend look broken in the browser. Check the response headers in curl from the same network.
  • Wrong HTTP method. A 405 response on the OPTIONS preflight isn't a CORS error per se — it's the server saying "I don't accept OPTIONS." The fix is to handle OPTIONS, not to fiddle with allow-origin.

Other form problems and fixes

Still stuck? Test your endpoint with our free Form Action Tester or contact splitforms support.

Skip CORS configuration entirely

The fastest way to never debug a CORS error on a form again: point the form at splitforms. The backend handles preflight, returns the right CORS headers per allowed origin, and the dashboard's allowed-origins list is the only thing you need to maintain. Sign up at /login; the free tier covers 500 submissions total. Pricing details at /pricing.

FAQ

Why does my <form> trigger a CORS error when I submit it?

It usually doesn't — the browser fires CORS errors for `fetch()` and `XMLHttpRequest`, not for native form submission with the default content type. If you're seeing a CORS error, check whether you're actually submitting via fetch (or whether some library is wrapping the submit). A plain `<form action="...">` with no JavaScript intercept doesn't trigger preflight, and the browser doesn't enforce CORS on the response (it follows the redirect or reloads the page).

What's the difference between `Content-Type: application/json` and `application/x-www-form-urlencoded`?

Browsers treat `application/json` as a non-simple request and trigger a preflight OPTIONS check. `application/x-www-form-urlencoded` and `multipart/form-data` are simple types — no preflight, no CORS error on the request. The fastest way to make a CORS error disappear on a form submission is to switch the fetch from JSON to URLSearchParams or FormData. The splitforms backend accepts both encodings.

Does setting `mode: 'no-cors'` on fetch fix it?

No, and it makes things worse. `mode: 'no-cors'` makes the browser send the request but blocks JavaScript from reading the response. The form submission appears to succeed but you can't tell whether it actually did, you can't read the server's response body, and you can't show a real success/error state. Never reach for `no-cors`; the right fix is on the server.

Why does the production form work but the preview deployment fails with CORS?

Because the form backend (or your own API) whitelists specific origins, and the preview URL isn't in the list. splitforms allows submissions only from the domains configured on the access key. Vercel preview URLs (`my-app-abc123.vercel.app`) are different origins from your production domain. Add the preview pattern to the access key's allowed origins, or use a wildcard (splitforms supports `*.vercel.app` style wildcards on Pro plans).

How do I fix CORS on my own backend (not a hosted form service)?

Add the right `Access-Control-Allow-Origin` and `Access-Control-Allow-Methods` headers to the response. For Node/Express, the `cors` middleware does this in two lines. For Next.js, set headers in `next.config.js` or in the route handler. Code examples for both are below. Never echo `Access-Control-Allow-Origin: *` on a request that includes credentials — the browser will reject it.

Why does the OPTIONS request never include the body?

By design. The OPTIONS preflight is a permission check the browser sends before the real request. The server replies with CORS headers indicating whether the cross-origin request is allowed; if yes, the browser then sends the real request with the body. If your server returns 405 or 404 on OPTIONS, the preflight fails and the real request never goes out. Always handle OPTIONS explicitly.

Will switching to a hosted form backend make my CORS problem go away?

Mostly, yes. splitforms returns the right CORS headers for every allowed origin and handles OPTIONS preflight correctly. The remaining gotcha is the access key's allowed-origins list — if your domain isn't on it, you still get a CORS error. Add your domain in the dashboard and the error disappears. See /forms/html for the form-side setup and /docs for the dashboard configuration.

Related articles

More practical guidance from guides.

Browse the journal →
Guides

Gravity Forms Not Sending Email? 5 Fixes That Work (2026)

Gravity Forms notifications not arriving? Find the wp_mail cause in 2 minutes and fix it wit…

7 min readRead →
Guides

Form Backend Migration Checklist: Fields, Files and Email

Move an existing form to SplitForms with a practical checklist for field mapping, redirects,…

6 min readRead →
Guides

Best MCP Servers for Building Forms with AI Agents (2026)

Ranked MCP servers that let Claude Code, Cursor, and other agents create forms and read subm…

12 min readRead →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe