Writing
Networking
Mingtindu Sherpa12 min read

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 requestsGET, 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, or POST (so PUT, PATCH, DELETE, etc.)
  • Headers outside the safelisted set (a custom Authorization header, for example)
  • A Content-Type other than application/x-www-form-urlencoded, multipart/form-data, or text/plain (so application/json counts)
  • 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 containsWhat 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 letting app.use(cors()) handle it, which it does automatically) is required so preflight OPTIONS requests get a response, unless you're setting per-route CORS with app.options('/route', cors()).
  • A comma-separated list is not a valid Access-Control-Allow-Origin value. 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-Origin headers, 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: true only 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 OPTIONS preflight response (if any) includes matching Access-Control-Allow-Methods and Access-Control-Allow-Headers.
  • Access-Control-Allow-Origin echoes 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 curl or 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.ts file for this pattern.

    • Why verification is needed: Newer Next.js releases rename this file convention to proxy.ts and change its default runtime; the correct file name depends on the exact installed version.
    • Suggested evidence: npx next --version output and the Next.js version-specific middleware/proxy documentation.
  • 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 -i output showing the actual headers returned in production, compared with what the application code sets.

Related writing

Share