Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Troubleshooting

React CORS Errors on Vercel: The Browser Is Not the Problem

Sean

Platform Writer

Aug 26, 2026
8 min read

CORS is enforced by the browser and configured on the server. When your React app works locally and fails once deployed, nothing about React changed — the origin did. Your API is sending Access-Control-Allow-Origin for localhost:3000 and your deployed app is now on a different domain.

React CORS Errors on Vercel: The Browser Is Not the Problem

The error is one of the most misread messages in web development, because it appears in the browser console inside your frontend code, and it is not about your frontend code at all.

The browser made the request. The server answered. The browser then looked at the response headers, did not find permission to share it with this origin, and refused to hand it to your JavaScript. Every real fix happens on the server.

Table of contents

Reading the error properly

The message names the exact failure, and the four common variants mean genuinely different things.

Access to fetch at 'https://api.example.com/users' from origin
'https://my-app.vercel.app' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
  • No Access-Control-Allow-Origin header — the server sent nothing. CORS is not configured, or the request never reached your handler.
  • Header has a value that is not equal to the supplied origin — CORS is configured for a different domain. Usually still localhost.
  • Response to preflight request does not pass access control check — the OPTIONS request failed. Your framework is probably not routing OPTIONS at all.
  • **Credentials flag is true but Access-Control-Allow-Origin is *** — the wildcard is not permitted with cookies. This is a spec rule, not a bug.

Match the message before changing anything. The fixes are different, and applying the wildcard fix to a credentials problem makes it worse.

Confirm what the server actually sends, outside the browser, so you are not guessing:

curl -I -H "Origin: https://my-app.vercel.app" https://api.example.com/users

If no access-control-allow-origin comes back in that response, the browser is behaving correctly and the server is the thing to fix.

Why it worked locally and broke on deploy

Locally, one of two things was hiding the problem.

A dev proxy. Create React App’s proxy field and Vite’s server.proxy make the browser think the API is same-origin, because the dev server forwards the request server-to-server. No cross-origin request happens, so no CORS check happens. That proxy does not exist in a production build.

{
  "proxy": "http://localhost:4000"
}

A permissive allowlist. The API allowed http://localhost:3000 and you were on http://localhost:3000.

Deploying changes the origin to https://my-app.vercel.app, and preview deployments change it again on every branch — my-app-git-feature-x.vercel.app. An allowlist with one production domain in it will still block every preview URL, which is why this often looks intermittent.

Handle that with a pattern rather than a list you have to maintain:

const allowed = [
  'https://my-app.vercel.app',
  'http://localhost:3000',
];

function isAllowed(origin) {
  if (!origin) return false;
  if (allowed.includes(origin)) return true;
  return /^https:\/\/my-app-[a-z0-9-]+\.vercel\.app$/.test(origin);
}

Anchor the regular expression at both ends. An unanchored pattern matching vercel.app will happily allow https://evil-vercel.app.attacker.com.

Preflight, which is where most of the time goes

For anything beyond a simple request, the browser sends an OPTIONS request first and waits for permission. Custom headers such as Authorization, a JSON content type, or any method other than GET, POST and HEAD trigger it.

The preflight must be answered by the server, and many routers do not register OPTIONS handlers automatically.

app.use(cors({
  origin: isAllowed,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}));

Three things people get wrong here:

  1. Any header your frontend sends must appear in allowedHeaders. Sending Authorization while allowing only Content-Type fails preflight every time.
  2. The preflight response must be a 2xx. Auth middleware that returns 401 on an unauthenticated OPTIONS request breaks CORS, because the browser never gets to send the real request. Mount CORS before auth.
  3. Without maxAge, the browser preflights on every single request. Setting it to 86400 caches the permission for a day and noticeably cuts request volume.

On Vercel itself, headers for static and serverless routes go in vercel.json:

{
  "headers": [{
    "source": "/api/(.*)",
    "headers": [
      { "key": "Access-Control-Allow-Origin", "value": "https://my-app.vercel.app" },
      { "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
    ]
  }]
}

Cookies and credentials

If your app authenticates with cookies rather than a bearer token, there are extra rules and the browser enforces all of them.

The client must opt in:

fetch(url, { credentials: 'include' });
// axios
axios.get(url, { withCredentials: true });

And the server must respond with an exact origin — never the wildcard — plus the credentials header:

app.use(cors({
  origin: 'https://my-app.vercel.app',
  credentials: true,
}));

Then the cookie itself has to survive a cross-site context. If your API and frontend are on different domains, the cookie needs SameSite=None and Secure, and Secure requires HTTPS on both ends:

res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'none',
});

Miss any one of those four and the request succeeds while the cookie silently does not travel, which presents as a login that appears to work and then immediately does not.

The wildcard, and why it is not the fix

Access-Control-Allow-Origin: * makes the error disappear. It also tells every browser that any website may read responses from your API.

For a genuinely public, unauthenticated, read-only endpoint, that is a reasonable and intentional choice. For anything that returns user data or accepts writes, it means a page on another domain can make requests as your logged-in user and read what comes back.

It also does not work with credentials at all — the spec forbids the combination, so if you are using cookies the wildcard cannot be your answer regardless.

The honest version of the shortcut is a reflected origin with a real check, which is what the isAllowed function above does. It costs a few lines and it keeps the property that matters: only origins you named can read the response.

How this fits the rest of the stack

CORS problems are mostly a symptom of the frontend and the API living in different places with configuration that has to be kept in agreement by hand. Every new preview URL, every domain change, and every new custom header is another chance for the two to drift apart.

Keeping the API and the frontend on one platform removes most of that surface. RunxBuild runs static sites and backend services on the same deployment path, with response headers configurable per site and custom domains handled with certificates, so the origin your API allows and the origin your app is served from stop being two independently managed lists. The RunxBuild hosting calculator shows what the frontend, the API and the database come to together, which is usually the number you actually want.

Useful related references:

FAQ

Why does my React app get CORS errors only after deploying?

Locally your dev server proxy made requests same-origin, so no CORS check ran, or your API allowlisted localhost:3000 and that is where you were. Deploying changes the origin, and the API has no rule for the new domain. Nothing in your React code is different.

How do I allow Vercel preview deployments through CORS?

Preview URLs change per branch, so a static list will not cover them. Use a function that checks the origin against a regular expression anchored at both ends, such as /^https:\/\/my-app-[a-z0-9-]+\.vercel\.app$/. An unanchored pattern can be matched by an attacker-controlled domain.

Can I fix CORS from the React side?

No. The headers that grant permission come from the server, and the browser enforces the rule before your JavaScript sees the response. The only client-side options are routing through a proxy you control or moving the API to the same origin — both change who answers the request, rather than changing the check.

Why does the preflight OPTIONS request fail?

Usually because authentication middleware runs before CORS and returns 401 on the unauthenticated OPTIONS request, or because a header your frontend sends is missing from allowedHeaders. Mount CORS before auth and make sure every custom header is listed.

Is Access-Control-Allow-Origin: * safe?

Only for public, unauthenticated, read-only endpoints. It permits any site to read your API’s responses. It is also incompatible with credentialed requests — the spec forbids the wildcard when cookies are involved — so if you use cookie sessions you must send an exact origin.

#react cors error vercel#cors#react#vercel#api