The React front end runs on localhost:3000, the Node API on localhost:8080, and the first fetch between them fails with a CORS error. Nothing is broken; the browser is refusing to hand your JavaScript a response from a different origin until the API says it is allowed. The fix lives in the Node code, takes about three lines, and this article covers it for Express, Fastify and plain Node, along with the development proxy that avoids the question entirely and the settings that production needs.
The situation#
// React, running at http://localhost:3000
const res = await fetch('http://localhost:8080/api/items');
// Access to fetch at 'http://localhost:8080/api/items' from origin 'http://localhost:3000'
// has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
Different port means different origin. The request reached the API and the API answered; the browser then looked for a permission header in the response, found none, and blocked your code from reading it. React cannot add that header. The API has to.
Express#
npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: ['http://localhost:3000', 'https://app.example.com'],
credentials: true, // only if you send cookies or auth headers
}));
app.use(express.json());
app.get('/api/items', (req, res) => res.json([{ id: 1 }]));
app.listen(8080);
The cors middleware adds the headers to every response and answers preflight OPTIONS requests automatically. Register it before your routes and before any authentication middleware, so that preflights — which carry no credentials — are not rejected with a 401 before CORS gets to answer them.
Fastify#
const fastify = require('fastify')();
await fastify.register(require('@fastify/cors'), {
origin: ['http://localhost:3000'],
credentials: true,
});
Plain Node, no framework#
const http = require('http');
const ALLOWED = new Set(['http://localhost:3000']);
http.createServer((req, res) => {
const origin = req.headers.origin;
if (ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
res.writeHead(204);
return res.end();
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify([{ id: 1 }]));
}).listen(8080);
This is what the middleware does for you. Note Vary: Origin, which stops a cache serving one origin’s permission header to another.
Preflight and middleware order#
A JSON POST, a PUT, a DELETE, or any request with an Authorization header triggers a preflight: the browser sends OPTIONS first and only sends the real request if the answer allows it. Two things break preflights:
- Authentication middleware registered before CORS rejects the credential-less OPTIONS with 401. Move CORS above it.
- A route handler that does not exist for OPTIONS returns 404. The middleware handles this; hand-written servers must answer OPTIONS explicitly.
In the Network tab, a failed preflight shows as a red OPTIONS request just before the request you made. That is the one to inspect.
Cookies and tokens#
Sending a session cookie or an Authorization header cross-origin needs opt-in on both sides:
// React
await fetch('http://localhost:8080/api/me', { credentials: 'include' });
// Node: credentials: true, and a specific origin - not '*'
app.use(cors({ origin: 'http://localhost:3000', credentials: true }));
Browsers reject Access-Control-Allow-Origin: * combined with credentials, which is why the origin list must be explicit. For cookies to be sent at all cross-site, they also need SameSite=None; Secure, which in turn requires HTTPS — on localhost, most browsers make an exception, but on a deployed site this is the step people miss.
The development proxy#
During development you can sidestep CORS by having the React dev server forward API calls, so the browser sees one origin:
// vite.config.js
export default {
server: {
proxy: { '/api': 'http://localhost:8080' },
},
};
// Create React App: package.json
{ "proxy": "http://localhost:8080" }
Then fetch /api/items with a relative URL. The dev server proxies it, no CORS headers needed. This is convenient and it hides the problem: the moment you deploy the front end and API to different origins, CORS is back. Set up the server-side headers anyway, so production is configured before you get there.
Production#
- Put the real front-end origin in the allowed list from an environment variable, not a hard-coded localhost.
- Or avoid cross-origin entirely: serve the API under the same domain as the front end, with a reverse proxy routing
/apito Node. Same origin, no CORS, and cookies are simpler. - Check that error responses carry the CORS headers too. An unhandled exception that bypasses the middleware produces a 500 the browser reports as a CORS error, hiding the real cause.
app.use(cors({ origin: process.env.FRONTEND_ORIGIN.split(','), credentials: true }));
Questions people ask#
Why does it work in Postman?
Postman is not a browser. CORS is enforced only by browsers, on behalf of the user’s cookies.
Do I need CORS if React and Node are on the same server?
Only if they are on different origins. Same domain, scheme and port means no CORS at all, which is a good reason to deploy them that way.
Next.js API routes?
Same-origin by default, so no CORS between a Next.js page and its own API routes. Calling an external API still needs that API’s permission, or a route of yours that proxies it.
The headers are set and it still fails.
Check the preflight in the Network tab, check the origin matches exactly including scheme and port, and check the failing response is not a 500 that skipped the middleware.
Where to go next#
- What is a CORS error? — the concept behind all of this.
- Handling fetch errors properly — what to do with the responses once they arrive.
- REST API error handling — making sure 500s carry the right headers.