Fix CORS Errors in Express and Next.js
Why CORS errors show up in the browser console, what the browser is actually hiding from you, and how to fix them correctly in Express and Next.js without opening every origin in production.
On this page
A CORS error looks like your API rejected the request. Usually it didn't. The request often reached your server, ran completely, and returned a normal response — the browser is the one refusing to hand that response to your frontend JavaScript.
That distinction changes how you should debug it. This article covers why CORS errors happen, how to read what the browser is actually telling you, and how to fix the two most common backends for a Node.js stack: Express and Next.js.
CORS is a browser rule, not a server rule
CORS (Cross-Origin Resource Sharing) is enforced entirely by the browser. The MDN CORS guide describes it as a header-based mechanism that lets a server tell the browser which other origins are allowed to read its responses. The key word is read. Your server still receives the request, still runs your handler, and still sends a response — the browser inspects the response headers afterward and decides whether to let your frontend code see the result.
This means:
- A tool like
curl, Postman, or a server-to-server request ignores CORS completely. It isn't a browser, so there's nothing enforcing the same-origin policy. - A CORS error in the console does not prove your API is unreachable, broken, or down. It proves the browser blocked the frontend script from reading a response.
- CORS headers are not an authentication or authorization mechanism. Anyone who can call your API directly (outside a browser) bypasses CORS by definition. Use real auth for protecting data — CORS only controls which websites' JavaScript can read your responses.
What actually triggers the error
Two categories of request exist, and only one of them involves an extra round trip:
Simple requests — GET, HEAD, or POST with only a small set of safelisted headers and body types — go straight to your server. The browser checks the response headers afterward.
Preflighted requests trigger an automatic OPTIONS request before the real one, whenever the actual request uses:
- A method other than
GET,HEAD, orPOST(soPUT,PATCH,DELETE, etc.) - Headers outside the safelisted set (a custom
Authorizationheader, for example) - A
Content-Typeother thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain(soapplication/jsoncounts) - Credentials in some configurations
The browser sends that OPTIONS request with Origin, Access-Control-Request-Method, and Access-Control-Request-Headers. Your server must respond with matching Access-Control-Allow-* headers before the browser will send the real request at all. If your server doesn't have a route or middleware that answers OPTIONS correctly, the real request never leaves the browser — which is why unexpected OPTIONS entries in your server logs are usually the first clue.
Reading the browser's error message
Typical console text looks like one of these (from the MDN CORS reference):
Access to fetch at 'https://api.example.com/orders' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Access to fetch at 'https://api.example.com/orders' from origin
'https://app.example.com' has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.Each message maps to a specific missing or wrong header — not a generic "CORS is broken" state:
| Message contains | What it means |
|---|---|
| "No 'Access-Control-Allow-Origin' header is present" | The server's response had no CORS headers at all — often because an error response (500, a thrown exception, a proxy failure) bypassed your CORS middleware entirely. |
| "does not match" | The server sent an Access-Control-Allow-Origin value that isn't the requesting origin. |
| "must not be the wildcard '*' when credentials mode is 'include'" | Covered below — wildcard and credentials can't be combined. |
| "Did not find method in CORS header 'Access-Control-Allow-Methods'" | The preflight response didn't list the method the real request needs. |
| "CORS preflight channel did not succeed" | The OPTIONS request itself failed or returned a non-2xx status before headers were even evaluated. |
Before touching CORS configuration, open the Network tab and inspect the actual response for the failing request (and its preflight, if there is one). A 500 error, an auth redirect, or a proxy timeout can all present as a CORS error in the console, because the browser reports "blocked by CORS policy" whenever it can't find a valid Access-Control-Allow-Origin on whatever response it did get — including an error response.
Why wildcard origins can't be combined with credentials
Access-Control-Allow-Origin: * allows any website's JavaScript to read the response. That's fine for a public, unauthenticated API. It becomes a real risk the moment cookies or Authorization headers are involved, because a wildcard origin combined with credentials would let any site make a credentialed request on a logged-in user's behalf and read the result.
The Fetch/CORS specification blocks this combination outright — browsers refuse to expose the response, and Express's cors middleware documents the same restriction: when credentials: true is set, origin cannot be * and must resolve to an explicit origin instead (see the Express cors middleware documentation). This isn't a bug to work around — it's the browser correctly refusing an unsafe configuration. The fix is always the same: enumerate the origins you actually trust.
Fixing it in Express
The cors package is the standard middleware. A minimal allowlist configuration:
const cors = require("cors");
const allowedOrigins = ["https://app.example.com", "https://admin.example.com"];
app.use(
cors({
origin(origin, callback) {
// Requests with no Origin header (curl, server-to-server) are not
// browser requests, so there's nothing for CORS to enforce here.
if (!origin || allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
})
);A few details that cause real bugs:
- Middleware order matters.
app.use(cors(...))must run before your routes, and before any error-handling middleware that might send a response of its own. If a route throws before the CORS middleware runs, the error response goes out without CORS headers — which the browser then reports as a missing-header CORS error, hiding the actual server error. app.options("*", cors())(or lettingapp.use(cors())handle it, which it does automatically) is required so preflightOPTIONSrequests get a response, unless you're setting per-route CORS withapp.options('/route', cors()).- A comma-separated list is not a valid
Access-Control-Allow-Originvalue. The header can only ever contain a single origin (or*). This is why the middleware reflects back the matching origin from your allowlist rather than sending the whole list.
You can generate a starting Express (or NestJS, Nginx, Next.js, Cloudflare Worker) configuration from your own allowed origins and methods with this site's CORS Configuration Generator — treat the output as a reviewed starting point, not a drop-in final config.
Fixing it in Next.js
Next.js gives you three separate places to set CORS headers, and they solve different problems.
Per-route, in a Route Handler
For one API route, set headers directly on the Response:
// app/api/orders/route.ts
export async function GET(request: Request) {
return new Response(JSON.stringify({ orders: [] }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}If you don't export OPTIONS yourself, Next.js implements a default one and sets the Allow header based on the other methods you defined — but it will not add Access-Control-Allow-* headers for you, so a preflighted cross-origin request will still fail unless you handle OPTIONS explicitly. See the Route Handlers reference.
For many routes at once, in middleware
For an allowlist that applies across multiple API routes, Next.js's own documentation recommends a middleware.ts file (Next.js 14/15) with an origin allowlist:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const allowedOrigins = ["https://app.example.com", "https://admin.example.com"];
const corsHeaders = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export function middleware(request: NextRequest) {
const origin = request.headers.get("origin") ?? "";
const isAllowed = allowedOrigins.includes(origin);
const isPreflight = request.method === "OPTIONS";
if (isPreflight) {
return NextResponse.json(
{},
{ headers: { ...(isAllowed && { "Access-Control-Allow-Origin": origin }), ...corsHeaders } }
);
}
const response = NextResponse.next();
if (isAllowed) response.headers.set("Access-Control-Allow-Origin", origin);
Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value));
return response;
}
export const config = {
matcher: "/api/:path*",
};This pattern is documented directly in Next.js's Middleware CORS example. Note the matcher — without one, the middleware runs on every request, including static assets, which is usually not what you want.
Globally, in next.config.js
For headers that don't depend on the request (a single fixed allowed origin, for example), next.config.js's headers() function can set them without any runtime code, but it can't easily implement per-origin allowlist logic since the config is static at build time.
Frontend vs. backend responsibility
The frontend cannot fix a CORS error by changing its own request — CORS headers come from the server's response, not the client's request configuration. mode: 'no-cors' on the frontend "fixes" the console error by making the response unreadable to your own JavaScript, which isn't a fix at all. The correct fix is always on the server (or the reverse proxy in front of it): add the requesting origin to the allowlist and respond correctly to preflight.
When a reverse proxy is involved
If Nginx, Cloudflare, a load balancer, or another proxy sits in front of your Node.js app, check where the CORS headers are actually set. Two common failure patterns:
- The proxy adds its own CORS headers, and so does your app — resulting in duplicate
Access-Control-Allow-Originheaders, which browsers reject. - The proxy strips or overwrites headers your app already set, so your Express or Next.js CORS configuration never reaches the browser at all.
Inspect the actual response headers at the network layer (curl -i) to see what's really being sent, rather than assuming your application code is the last thing touching the response.
Common mistakes
Reflecting every origin unconditionally
origin: true in the cors package reflects whatever Origin header the request sent, allowing any site to pass. That's effectively the same trust boundary as *, just without triggering the wildcard-plus-credentials browser restriction — combined with credentials: true it lets any website make authenticated requests on behalf of a logged-in user. Use an explicit allowlist instead.
Setting CORS headers only on success responses
If your error-handling middleware (or a thrown exception) produces a response before your CORS middleware runs, that response won't carry CORS headers, and the browser reports it as a CORS failure — hiding the real error status underneath.
Assuming a passing curl request means CORS is fine
curl doesn't enforce CORS, so a successful curl request tells you the server and route work, but nothing about whether the browser will accept the response. Test from an actual browser, or inspect the response headers directly.
Not handling OPTIONS for every method you use
Adding a new HTTP method or custom header to an existing endpoint can change a previously "simple" request into a preflighted one. If the corresponding OPTIONS handling wasn't updated, only the newly changed endpoint starts failing.
Production allowlists
Do not respond to every request with Access-Control-Allow-Origin: * in production once credentials or sensitive data are involved — this article does not recommend that as a general fix. Instead:
- Maintain an explicit list of trusted origins (per environment — local, staging, production frontends are usually different origins).
- Store that list in configuration, not scattered across routes.
- Set
Access-Control-Allow-Credentials: trueonly for origins that legitimately need cookie- or header-based auth. - Re-verify the allowlist whenever a new frontend domain, subdomain, or preview-deployment pattern is added.
Verification checklist
- The Network tab shows the actual response status for the failing request (not just the console error).
- The
OPTIONSpreflight response (if any) includes matchingAccess-Control-Allow-MethodsandAccess-Control-Allow-Headers. -
Access-Control-Allow-Originechoes the exact requesting origin, not a wildcard, when credentials are used. - The CORS middleware or headers run before any error-handling middleware that could short-circuit the response.
- The fix was verified from an actual browser request, not only
curlor Postman. - No reverse proxy in front of the app is adding or stripping conflicting CORS headers.
- The production allowlist contains only origins that are actually trusted.
Claims to manually verify before publishing
-
Claim: This project's actual Next.js version uses a
middleware.tsfile for this pattern.- Why verification is needed: Newer Next.js releases rename this file convention to
proxy.tsand change its default runtime; the correct file name depends on the exact installed version. - Suggested evidence:
npx next --versionoutput and the Next.js version-specific middleware/proxy documentation.
- Why verification is needed: Newer Next.js releases rename this file convention to
-
Claim: A specific CORS error you encountered matches one of the message patterns quoted here.
- Why verification is needed: This article currently uses documented example error text, not a captured incident.
- Suggested evidence: A sanitized screenshot or copy of the exact browser console error and the corresponding Network tab response.
-
Claim: A reverse proxy is involved in your deployment and duplicates or strips CORS headers.
- Why verification is needed: This is general guidance about a common failure pattern, not a confirmed cause in a specific incident.
- Suggested evidence: Sanitized
curl -ioutput showing the actual headers returned in production, compared with what the application code sets.
Related writing
- Configure Cloudflare DNS on UbuntuConfigure Cloudflare's resolver on an Ubuntu device and separately manage authoritative Cloudflare DNS records for a domain.
- WebSockets vs HTTP — connection economics, backpressure, and when streaming winsFraming the transport choice as an operations problem: fan-out, heartbeats, scaling stateful sockets, and falling back to SSE or polling without shame.
- Deploy a Next.js Application on VercelImport a Next.js repository into Vercel, configure builds and environments, connect a domain, inspect logs, and verify production behavior.