Next.js

CORS Not Working on Vercel? Fix the vercel.json Headers

Why a CORS header that passes on localhost can still get blocked once Vercel routes the request, and the one-layer fix Vercel documents for it.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

Vercel's own documentation is blunt about the starting point: Vercel Functions do not add CORS headers for you, in any framework. So the header has to be set somewhere, and on Vercel that "somewhere" is one specific layer — vercel.json, framework config, Routing Middleware, or the function itself. This exact question is what happens when an Express app's own cors() call (which works, reproduced below) is the only layer configured, but a legacy vercel.json builds/routes setup doesn't carry that header the way the reader expected. A second, unrelated cause blocks the exact same browser error: Deployment Protection answering the preflight with a 401 before your code runs at all. Fix: set the header in exactly one layer, and if Deployment Protection is on, allowlist OPTIONS for your API path.

text
Access to XMLHttpRequest at 'https://apiurl/' from origin 'http://localhost:3000' has
been blocked by CORS policy: Response to preflight request doesn't pass access control
check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Why it happens#

Vercel's own guide to enabling CORS answers one question directly, in its FAQ: "No. Vercel Functions don't add CORS headers for you, whether you run them standalone or through a framework. Every function type needs an explicit CORS policy, set in the function code, vercel.json, your framework config, or Routing Middleware." That is about Vercel never adding a header on your behalf — it says nothing about what happens to a header your own code already sets, which is the actual question in the report behind this error.

That report shows an Express API, deployed with a version: 2 vercel.json using builds + routes to point everything at index.js, already called app.use(cors({ origin: "*" })). The asker was explicit that it worked locally and only failed once deployed — "I was testing on my local with setting enable CORS. There is problem with the snippet code above."

Running that same index.js locally (Node v26.7.0, express 4.21.2, cors 2.8.5, 26 October 2026 — no Vercel account needed for this half) confirms the npm package itself isn't what's broken:

text
$ curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" http://127.0.0.1:3999/
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
 
$ curl -s -D - -o /dev/null -X OPTIONS -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" http://127.0.0.1:3999/
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE

cors({ origin: "*" }) sets Access-Control-Allow-Origin on both the real GET and the OPTIONS preflight with no extra code — exactly what the asker already saw locally. So the request was reaching the Express app: the route's src: "/(.*)" had no methods property, and Vercel's own reference says a route with that property omitted "matches any HTTP method" — OPTIONS included. What the accepted answer (score 20) found was narrower and came only from their own debugging, not from any Vercel doc: Vercel "overwrites the cors settings you put anywhere outside the json", so the header cors() set inside the Express app did not survive once deployed, and at the time a headers array could not be combined with a routes array in the same vercel.json. The answer also flags a detail easy to miss — don't drop OPTIONS from the method list, since a cross-origin request from the browser often arrives as an OPTIONS preflight before the real one. The fix that worked was to move the header onto the matching route itself:

vercel.json (the accepted answer's fix)
json
{
  "version": 2,
  "builds": [{ "src": "./index.js", "use": "@vercel/node" }],
  "routes": [
    {
      "src": "/(.*)",
      "dest": "./index.js",
      "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
      "headers": { "Access-Control-Allow-Origin": "*" }
    }
  ]
}

Two things about that explanation are worth checking against what Vercel documents today. First, the "not possible to mix" restriction: Vercel's current vercel.json reference states the opposite now —

"You can use routes alongside rewrites, redirects, headers, cleanUrls, and trailingSlash." — Static Configuration with vercel.json

Second, builds itself: the same reference now lists it under "Legacy" and says plainly —

"We recommend against using this property. To customize Vercel functions, please use the functions property instead." — Static Configuration with vercel.json

So the route object's own headers field is still valid syntax — it isn't on the reference's short list of deprecated route properties — but builds/routes is the configuration style Vercel now steers people away from, and the per-route headers fix is no longer the only option.

A second, unrelated cause behind the same error: Deployment Protection#

If the Express or Next.js code already sets the header correctly and the error still shows up, check Deployment Protection before touching CORS code again. Vercel's CORS guide names this failure mode directly:

"When Deployment Protection through Vercel Authentication, Password Protection, or Trusted IPs is on, an unauthenticated preflight OPTIONS request gets a 401 from Vercel before your middleware or function runs. The browser never sees your CORS headers, so the actual request is never sent." — How to enable CORS on Vercel

A 401 is not an "ok status" for a preflight, so the browser reports exactly the same has been blocked by CORS policy wording, even though no CORS header was ever wrong. The fix is the OPTIONS Allowlist, which exempts a path prefix such as /api from Deployment Protection for OPTIONS requests only — new projects have /api/* on it by default, so this mainly bites a project created before that default, or an API outside /api.

Fix#

Vercel's guide groups the fix by layer, and the first rule is to use one of them for a given route — adding a header in two places risks a duplicate or inconsistent value, which is its own, separate bug to debug later.

1. A plain Express (or Next.js Route Handler) app, no other routing rules#

Delete builds/routes from vercel.json if it exists only to point at your entry file — Vercel's zero-configuration detection finds a default-exported Express app on its own — and keep cors() as the one layer that sets the header:

index.ts
typescript
import express from 'express';
import cors from 'cors';
 
const app = express();
 
app.use(cors({ origin: 'https://your-frontend.example.eu' }));
 
app.get('/', (req, res) => {
  res.json({ ok: true });
});
 
export default app;

If removing vercel.json turns the CORS error into a 404: NOT_FOUND instead, the entry file isn't where Vercel expects it — see Fix Vercel 404: NOT_FOUND Deploying an Express App for the exact file names and locations it checks.

2. The same static header on every request to a path, regardless of code#

Set it once in vercel.json's headers property — current docs confirm this can sit next to routes in the same file, so you don't have to touch a legacy configuration to add it:

vercel.json
json
{
  "headers": [
    {
      "source": "/api/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Origin", "value": "https://your-frontend.example.eu" },
        { "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" }
      ]
    }
  ]
}

A Next.js project can set the same shape in next.config.ts instead, with an async headers() function matched by path — useful when you'd rather keep configuration inside the framework than in a second file.

3. The allowed origin depends on the request (several origins, previews, an allowlist)#

vercel.json and framework config only take static values. When the origin must be validated per request — a per-environment allowlist, or a different origin on every preview deployment — Vercel's guide points at Routing Middleware, which runs before your function and can answer the preflight itself:

middleware.ts
typescript
import { NextRequest, NextResponse } from 'next/server';
 
const allowedOrigins =
  process.env.NODE_ENV === 'production'
    ? ['https://your-frontend.example.eu']
    : ['http://localhost:3000'];
 
export function middleware(request: NextRequest) {
  const origin = request.headers.get('origin');
  const isAllowedOrigin = origin !== null && allowedOrigins.includes(origin);
 
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 200,
      headers: {
        ...(isAllowedOrigin ? { 'Access-Control-Allow-Origin': origin } : {}),
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        Vary: 'Origin',
      },
    });
  }
 
  const response = NextResponse.next();
  if (isAllowedOrigin) response.headers.set('Access-Control-Allow-Origin', origin);
  return response;
}
 
export const config = { matcher: '/api/:path*' };

If you have the failing request's console error in front of you and want to work out which layer is actually answering it before editing anything, paste it here:

Everything runs in your browser — nothing you paste is sent anywhere, and tokens, cookies and passwords are masked in the output.

Optional: response headers (curl -i / curl -v output)
Try:

Verify the fix#

Locally, repeat the two curl commands from the reproduction above against your own route and confirm both the real request and the OPTIONS preflight carry Access-Control-Allow-Origin. Once deployed, run the same two commands against your *.vercel.app URL — Vercel's own guide verifies the Deployment Protection fix the same way:

text
curl -i -X OPTIONS https://your-project.vercel.app/api/hello \
  -H "Origin: https://your-frontend.example.eu" \
  -H "Access-Control-Request-Method: POST"

A 200 or 204 with the expected headers means the fix landed; a 401 with no CORS headers at all means Deployment Protection, not your CORS code, is still answering first.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Next.js Route Handlers: the automatic OPTIONS carries no CORS header#

There's no cors() package to reach for in a Next.js Route Handler, and it's easy to add the header to GET only, since that's the "real" request, and assume the preflight is Next.js's problem to solve. It isn't. Reproduced directly (Next.js 16.4.3, Turbopack, 26 October 2026): a route exporting only GET, with no CORS header at all —

app/api/test/route.js
typescript
export async function GET() {
  return Response.json({ ok: true });
}

— answers a plain GET and a cross-origin preflight like this:

text
$ curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" http://127.0.0.1:59987/api/test
HTTP/1.1 200 OK
content-type: application/json
 
$ curl -s -D - -o /dev/null -X OPTIONS -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" http://127.0.0.1:59987/api/test
HTTP/1.1 204 No Content
allow: GET, HEAD, OPTIONS

Next.js answers the OPTIONS request for you here, but only with allow, which lists the route's supported methods — it is not Access-Control-Allow-Origin, and neither response above carries one. Adding the header to GET alone is not enough: both GET and an explicit OPTIONS export need it — two changes, not one, confirmed by re-running both requests after making them:

app/api/test/route.js
typescript
export async function GET() {
  return Response.json(
    { ok: true },
    { headers: { 'Access-Control-Allow-Origin': 'http://localhost:3000' } }
  );
}
 
export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': 'http://localhost:3000',
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
    },
  });
}
text
$ curl -s -D - -o /dev/null -H "Origin: http://localhost:3000" http://127.0.0.1:56566/api/test
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:3000
content-type: application/json
 
$ curl -s -D - -o /dev/null -X OPTIONS -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: GET" http://127.0.0.1:56566/api/test
HTTP/1.1 204 No Content
access-control-allow-methods: GET, OPTIONS
access-control-allow-origin: http://localhost:3000

To apply the same headers to several route files at once instead of repeating both exports everywhere, use the next.config.ts layer from the Fix section above, matched to your API path.

Two situations that look like this error but need a different fix entirely: if the browser itself suggested mode: 'no-cors' and your code took that suggestion, the response is now opaque and the fix is to remove no-cors, not to add more headers — see Set the request's mode to 'no-cors'? What to do instead. And if the blocked request is actually your own fetch() following a redirect into a third-party sign-in page, no header on your server or in your vercel.json can fix a response you don't control — see Access to fetch at accounts.google.com blocked by CORS: fix for why that needs a navigation, not a CORS header. If you haven't set up cors() with an explicit origin and credentials at all yet, start with Fix: blocked by CORS policy from origin localhost:3000 before layering a vercel.json or middleware fix on top of code that doesn't send the header yet either.

Vercel documents four valid places to put a CORS header and one way Deployment Protection can block the preflight before any of them run; the error looks identical from the browser from any of those causes, which is exactly why the fix is to work out which layer is actually answering the request, rather than editing cors() again because it worked before.

Further reading: Fix: Cannot set property Request of #<Object> getter.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote