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.
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.
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:
$ 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,DELETEcors({ 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:
{
"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
routesalongsiderewrites,redirects,headers,cleanUrls, andtrailingSlash." — 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
functionsproperty 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
OPTIONSrequest gets a401from 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:
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:
{
"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:
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)
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:
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 —
export async function GET() {
return Response.json({ ok: true });
}— answers a plain GET and a cross-origin preflight like this:
$ 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, OPTIONSNext.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:
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',
},
});
}$ 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:3000To 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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Vercel 404: NOT_FOUND Deploying an Express App
Why Vercel answers 404: NOT_FOUND for an Express app that runs locally, and the two things to check: the entry file Vercel looks for, and a leftover Build command/Output Directory setting.
Fix: Cannot set property Request of #<Object> getter
Reproduced line by line with Next.js's own polyfill file and abortcontroller-polyfill's published source across Node 16, 17 and 18, plus which of two independent fixes actually applies.
Next.js Middleware Not Running on Vercel: 3 Causes
Middleware that works locally can disappear in Vercel production because the matcher never matches, Edge bundles a Node-only import, or auth middleware cannot read the token. This guide gives you the diagnosis order I use before touching app code.


