ExpressJS

Fix “Blocked by CORS Policy” in a MERN App (React + Express)

a gaming laptop and headphones
On this page
  1. What counts as a different origin
  2. Fix 1: Configure the cors package in Express
  3. Fix 2: Cookies and logins need credentials on both sides
  4. Fix 3: Skip CORS in development with a Vite proxy
  5. Mistakes that keep the error alive
  6. How a CORS check actually works
  7. Simple requests
  8. Preflighted requests
  9. Read the error in the browser’s Network tab
  10. A production-ready CORS configuration
  11. Cookies across subdomains and domains
  12. Setting CORS in Nginx instead of Express
  13. Test CORS from the command line
  14. CORS checklist
  15. Frequently asked questions
  16. Oh hi there 👋It’s nice to meet you.
  17. Sign up to receive awesome content in your inbox.

Your React app calls your Express API and the browser console shows something like:

Access to fetch at 'http://localhost:5000/api/users' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

The request often reached your server. The browser blocked your JavaScript from reading the response, because the API didn’t say this origin is allowed. Postman and curl never show this error because CORS is only enforced by browsers. The fix belongs on the server.

What counts as a different origin

An origin is the scheme, host and port together. http://localhost:5173 (Vite) and http://localhost:5000 (Express) are different origins because the ports differ, so the browser applies CORS rules between them.

Fix 1: Configure the cors package in Express

npm install cors
const express = require('express');
const cors = require('cors');

const app = express();

const allowedOrigins = [
  'http://localhost:5173',          // Vite dev server
  'https://app.yourdomain.com',     // production frontend
];

app.use(cors({ origin: allowedOrigins }));
app.use(express.json());

app.get('/api/users', (req, res) => res.json([{ name: 'Aarav' }]));

app.listen(5000);

Two details matter. First, app.use(cors(…)) must come before your routes. Second, the origin must match exactly: no trailing slash, and the right port and scheme.

The cors middleware also answers the browser’s preflight OPTIONS requests, which are sent before PUT, DELETE, or requests with JSON bodies and custom headers such as Authorization.

Fix 2: Cookies and logins need credentials on both sides

If you use cookie-based sessions or a JWT in an HTTP-only cookie, the error changes to mention credentials. You need three things together:

// Express
app.use(cors({
  origin: ['http://localhost:5173', 'https://app.yourdomain.com'],
  credentials: true,
}));

// React with fetch
fetch('http://localhost:5000/api/me', { credentials: 'include' });

// React with Axios
axios.get('http://localhost:5000/api/me', { withCredentials: true });

With credentials, the browser rejects Access-Control-Allow-Origin: *. You must list real origins. For cookies across different domains in production, the cookie also needs sameSite: 'none' and secure: true, which requires HTTPS.

Fix 3: Skip CORS in development with a Vite proxy

A proxy makes the browser think the API is on the same origin as the frontend, so no CORS check happens:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:5000',
    },
  },
});

Now call fetch('/api/users') with a relative URL. In production, serve the React build from the same domain as the API, or keep the cors config from Fix 1.

Mistakes that keep the error alive

  • The real problem is a crash or a dead server. If the server is down, a proxy fails, or the request errors out before the cors middleware runs, the response has no CORS header, so the browser reports CORS instead of the real error. Check your server logs first.
  • Wrong URL. A 404 from a mistyped path can look like a CORS error. Open the API URL directly in the browser to confirm it exists.
  • Adding headers in React. Setting Access-Control-Allow-Origin in your fetch request does nothing. Only the server’s response headers count.
  • Express 5 route patterns. Old snippets use app.options('*', cors()). In Express 5 a bare * throws “Missing parameter name”. You don’t need that line, because app.use(cors()) already handles preflight requests.
  • Allowing every origin in production. cors() with no options allows any website to read your public API responses. For APIs used only by your own frontend, list your origins.

How a CORS check actually works

Understanding the conversation between the browser and your server makes every CORS error easier to read. There are two kinds of requests.

Simple requests

A plain GET, or a POST with a form-style content type and no custom headers, goes straight to the server. The browser adds an Origin header saying where the page came from. When the response arrives, the browser checks for Access-Control-Allow-Origin. If it matches the page’s origin, your JavaScript can read the response. If not, the response is thrown away and you see the CORS error, even though the server did the work.

Preflighted requests

Requests that could change data in unusual ways get checked first. That includes PUT, PATCH and DELETE, any request with Content-Type: application/json, and requests with headers such as Authorization. Before the real request, the browser sends an OPTIONS request asking “may this origin use this method and these headers?”. The server must answer with matching Access-Control-Allow-Methods and Access-Control-Allow-Headers. Only if the answer is yes does the browser send the real request.

This explains a confusing symptom: your GET routes work but your POST with JSON fails. The preflight is the request being rejected, and it never reaches your route handler.

Read the error in the browser’s Network tab

Open developer tools, go to the Network tab, and repeat the action. Look for two rows with the same URL: one OPTIONS (the preflight) and one with your real method.

  • The OPTIONS row failed or returned 404. Nothing on the server answered the preflight. Make sure app.use(cors(…)) comes before your routes and before any authentication middleware, because preflight requests never include your token.
  • The OPTIONS row succeeded but has no Access-Control-Allow-Origin. The origin isn’t in your allowed list. Compare it character by character, including http versus https and the port.
  • The real request returned 500. The server crashed. The CORS message is a side effect; fix the server error shown in your logs.

A production-ready CORS configuration

Hard-coding origins works for small projects. For an app with several environments, read them from an environment variable and allow requests without an Origin header, such as server-to-server calls and health checks:

// .env
// CORS_ORIGINS=http://localhost:5173,https://app.yourdomain.com,https://admin.yourdomain.com

const allowed = (process.env.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean);

app.use(cors({
  origin(origin, callback) {
    if (!origin || allowed.includes(origin)) return callback(null, true);
    return callback(null, false); // no CORS headers: the browser blocks it
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 600, // browsers may cache the preflight answer for 10 minutes
}));

Passing false to the callback, rather than an error, simply leaves out the CORS headers. That keeps unknown origins blocked without filling your logs with stack traces.

The maxAge option reduces the number of OPTIONS requests, which makes your app feel faster on slow mobile networks.

Cookies across subdomains and domains

Cookie-based logins add a second set of rules on top of CORS. Which settings you need depends on where your frontend and API live:

Setup Cookie settings
Same origin (Express serves React, or a Vite proxy) sameSite: 'lax' or 'strict'; no CORS needed
Subdomains: app.site.com and api.site.com Counted as the same site, so sameSite: 'lax' works; CORS still needed because the origins differ
Different domains: site.vercel.app and api.onrender.com sameSite: 'none' and secure: true, HTTPS on both, plus CORS with credentials

Different domains is the hardest case, and some browsers block third-party cookies entirely. If you can, put the API on a subdomain of your main domain, such as api.yourdomain.com. It avoids most cookie problems.

Setting CORS in Nginx instead of Express

If Nginx sits in front of Node, you can add the headers there. Choose one place only. Setting CORS headers in both Nginx and Express produces duplicate Access-Control-Allow-Origin headers, and browsers reject responses with more than one value.

location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin "https://app.yourdomain.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Allow-Credentials "true" always;
        return 204;
    }
    add_header Access-Control-Allow-Origin "https://app.yourdomain.com" always;
    add_header Access-Control-Allow-Credentials "true" always;
    proxy_pass http://127.0.0.1:5000;
}

For most MERN apps, keeping CORS in Express with the cors package is simpler, because it lives next to your code and your tests.

Test CORS from the command line

You can imitate the browser’s preflight with curl and read the headers your server returns:

curl -i -X OPTIONS http://localhost:5000/api/users \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

A working setup returns a 2xx status with Access-Control-Allow-Origin: http://localhost:5173 and the allowed methods. Change the Origin header to an unknown site and confirm that the header disappears.

CORS checklist

  1. Is the API actually running and returning 2xx for this route? Check the server logs before anything else.
  2. Is app.use(cors(…)) registered before your routes and before authentication middleware?
  3. Does the allowed origin match the browser’s origin exactly, including scheme and port, with no trailing slash?
  4. Sending cookies? Then credentials: true on the server, credentials: 'include' or withCredentials: true on the client, and no * origin.
  5. Different domains? Then sameSite: 'none', secure: true and HTTPS on both sides.
  6. Are CORS headers set in exactly one place: Express or Nginx, not both?
  7. In development, would a Vite proxy remove the problem entirely?

Frequently asked questions

Why does the API work in Postman but not in React?

CORS is a browser security rule. Postman, curl and server-to-server calls don’t enforce it, so they succeed while the browser blocks the same response.

Is a CORS error a frontend or backend problem?

Backend. The server must send headers that allow your frontend’s origin. In development, a Vite proxy is a frontend-side workaround.

Can I use origin “*” with cookies?

No. When credentials are included, the browser requires the exact origin in Access-Control-Allow-Origin, plus Access-Control-Allow-Credentials: true.

Why do I see two requests for one fetch call?

The first is the browser’s OPTIONS preflight check, the second is your real request. It is normal for JSON requests and requests with an Authorization header.

Does CORS protect my API from other servers?

No. CORS only controls what browsers let web pages read. Other servers and scripts can still call your API, so use authentication and rate limiting for real protection.

logo

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Saurabh

Full-stack developer writing practical guides on the MERN stack, AI and DevOps. Every code sample on this site is tested before it is published.