splitforms.com
All articles/ GUIDES12 MIN READPublished May 8, 2026

CORS Error on Form Submission — Complete Fix Guide (2026)

Diagnose and fix CORS errors on HTML form submissions. Why a normal form doesn't trigger CORS, what does, and the four fixes that actually work in 2026.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

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.

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

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 1,000 submissions/month. 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.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open