CORS error: what it means and how to fix it
Kofi Agyem, Founder and CEO, CloudzyroPublished · 7 min read
On this page
A CORS error means the browser blocked a response because the server didn't allow your origin. How CORS and preflight work, with Express and Next.js fixes.
1. The short answer
A CORS error means your page asked a server on another origin for something, and the server's response didn't say your origin is allowed to read it, so the browser blocked it. The fix always lives on the server you're calling: it has to send Access-Control-Allow-Origin and, for some requests, a few more headers. Nothing you change in the browser or in your frontend code can grant that permission.
If you don't control that server, the reliable fix is to call it from your own backend, where CORS doesn't apply.
2. What CORS is
Browsers follow the same-origin policy: a script on one origin can't read responses from another. An origin is the scheme, host and port together. These are all different origins:
http://localhost:3000andhttp://localhost:5173(different port);http://example.comandhttps://example.com(different scheme);https://example.comandhttps://www.example.com(different host, see www vs non-www);https://www.example.comandhttps://api.example.com.
Cross-Origin Resource Sharing (CORS) is how a server relaxes that rule. It adds response headers that tell the browser which other origins may read its responses. The browser does the checking. That's why the same request works in curl, Postman or server code: they don't enforce CORS at all.
The error appears in the browser's console, with wording like:
Access to fetch at 'https://api.example.com/items' from origin
'https://www.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.Your JavaScript only sees a vague TypeError: Failed to fetch. Browsers hide the details from scripts on purpose, so always read the console and the Network tab.
3. Simple requests and preflight
Some requests go straight to the server. A simple request is a GET, HEAD or POST with only basic headers and a Content-Type of text/plain, multipart/form-data or application/x-www-form-urlencoded. The browser sends it, then checks the response for Access-Control-Allow-Origin.
Everything else gets a preflight first: an OPTIONS request asking permission. That includes most API calls, because sending JSON (Content-Type: application/json), using PUT, PATCH or DELETE, or adding an Authorization header all trigger one.
OPTIONS /items HTTP/1.1
Origin: https://www.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: OriginIf the preflight answer is missing a header, lists the wrong ones, or comes back with a status outside 200 to 299 (a 404, a 401 from your login check, or a redirect), the browser never sends the real request.
4. The headers that matter
- `Access-Control-Allow-Origin`: the origin allowed to read the response, such as
https://www.example.com, or*for anyone. Only one value is allowed, so to accept several origins, check the request'sOriginagainst your list and echo back the one that matches. - `Access-Control-Allow-Methods`: the methods allowed after a preflight.
- `Access-Control-Allow-Headers`: the request headers allowed, such as
content-typeandauthorization. - `Access-Control-Allow-Credentials: true`: needed when the request sends cookies (
credentials: "include"in fetch). With it,Access-Control-Allow-Originmust be the exact origin, never*. - `Access-Control-Expose-Headers`: response headers your script may read beyond the basic ones.
- `Access-Control-Max-Age`: how long, in seconds, the browser may reuse a preflight answer.
- `Vary: Origin`: tells caches the response depends on the origin. Add it whenever you echo the origin back, or a CDN may serve one origin's answer to another.
5. Common causes
- The server sends no CORS headers at all. The usual case with a new API.
- The origin doesn't match exactly. The allow list has
https://example.combut the page runs onhttps://www.example.com, or a trailing slash snuck in (https://example.com/isn't an origin). - Wildcard with credentials.
*plus cookies orcredentials: "include"is always rejected. - The preflight fails. No
OPTIONShandler, an auth check that rejects theOPTIONSrequest, or a missing header inAccess-Control-Allow-Headers. - A redirect. The API redirects http to https, or adds a trailing slash, and the preflight can't follow it. Call the final address directly.
- An error response without headers. The app crashes, the error handler answers 500 without CORS headers, and the browser reports CORS instead of the crash.
- Two layers add the header. Both your app and a proxy add
Access-Control-Allow-Origin, and the browser rejects the duplicate value.
6. How to fix it, step by step
- Open the Network tab and find the failing request. If there's an
OPTIONSrequest before it, look at that first: its status and response headers. - Check the status code. A 404, 401 or 500 means fix that first. CORS headers on an error page won't help.
- Compare origins character by character. Copy the page's origin from the address bar (scheme, host, port, no path) and compare it with the allowed value.
- Add the headers on the server, including on
OPTIONSresponses and error responses. Examples below. - **Don't use
*with cookies.** Name the origin and sendAccess-Control-Allow-Credentials: true. - If it's not your server, move the call to your backend, or check whether the service expects you to call it from a server with a secret key anyway.
One thing never to do: turn off web security in your browser, or install an extension that strips CORS. It hides the problem on your machine only, and every visitor still sees the error.
7. Express
The cors package handles preflights and the headers for you:
// npm install cors
import express from "express";
import cors from "cors";
const app = express();
const allowed = (process.env.ALLOWED_ORIGINS || "").split(",");
app.use(
cors({
origin: allowed, // e.g. https://www.example.com,http://localhost:5173
credentials: true, // only if you send cookies
}),
);
app.use(express.json());
app.post("/items", (req, res) => res.json({ ok: true }));Put app.use(cors(...)) before your routes and any auth middleware, so preflights are answered before a login check can reject them. Keeping the list in an environment variable means you can add your live domain without changing code. More on hosting Node.js apps.
8. Next.js route handlers
In the App Router, export an OPTIONS handler for the preflight and add the same headers to every response:
// app/api/items/route.ts
const allowed = ["https://www.example.com", "http://localhost:5173"];
function cors(origin: string | null) {
const headers: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type, authorization",
Vary: "Origin",
};
if (origin && allowed.includes(origin)) {
headers["Access-Control-Allow-Origin"] = origin;
}
return headers;
}
export async function OPTIONS(req: Request) {
return new Response(null, {
status: 204,
headers: cors(req.headers.get("origin")),
});
}
export async function POST(req: Request) {
const data = await req.json();
return Response.json(
{ ok: true, data },
{ headers: cors(req.headers.get("origin")) },
);
}If your frontend and API are in the same Next.js app on the same domain, you don't need CORS at all: same-origin requests are never blocked. See hosting Next.js.
9. Supabase
Supabase's own APIs (the database API, Auth and Storage) already answer browsers from any origin, so a CORS error there usually isn't really about CORS: check the project URL and key in your settings, and look for a 401 or 404 in the Network tab.
Edge Functions are different. You write the responses, so you add the headers and answer the preflight yourself, as Supabase's docs show:
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
const body = JSON.stringify({ ok: true });
return new Response(body, {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
});Include the headers on error responses too. Setting up a Supabase project for a site? See how to connect Supabase.
10. On Cloudzyro
On Cloudzyro Sites & Apps, a site that serves both its pages and its API from one address has no CORS to configure. If your frontend and API are separate sites, or you add a custom domain, add each address your pages load from to the API's allow list, for example with an environment variable set on the site's Environment variables tab, then deploy. Remember that www.example.com, example.com and yoursite.cloudzyro.app are three different origins.
11. How to check
Send a preflight yourself with curl and read the headers that come back:
curl -i -X OPTIONS https://api.example.com/items \
-H 'Origin: https://www.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'You want a 2xx status and an Access-Control-Allow-Origin that exactly matches the origin you sent. Then reload the page with the Network tab open and confirm the real request goes out after the preflight.