What you see
Everything worked locally. You deploy the front-end to one host (say https://yourapp.netlify.app) and the API somewhere else (a Supabase Edge Function, or an Express server on Render). Now every API call fails, and the console shows something like:
Access to fetch at 'https://api.example.com/items' from origin 'https://yourapp.netlify.app'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Firefox words it as Reason: CORS header 'Access-Control-Allow-Origin' missing. In your JavaScript you only get a generic TypeError: Failed to fetch. MDN explains why: for security reasons the details of a CORS failure are not available to JavaScript, so the console is the only place to see what went wrong.
Why it happens
Browsers only let a page read responses from its own origin (scheme, domain and port) unless the other server says it’s allowed, using CORS response headers. Your front-end and API now have different origins, so the API has to send Access-Control-Allow-Origin.
For anything other than a simple request (a PUT or DELETE, a JSON body, or headers like Authorization), the browser first sends a preflight: an OPTIONS request asking which methods and headers are allowed. If that OPTIONS request gets a 404, a 401, or a response without the CORS headers, the real request is never sent.
CORS is enforced by the browser, not the server. As the Express docs put it, your server still receives and processes requests, and tools like curl or Postman ignore CORS entirely. That’s why the same call “works in Postman” but not in the app. It also means CORS is not access control: you still need authentication on your API.
Fix it in a Supabase Edge Function
Edge Functions called from the browser must handle the preflight. Supabase’s current docs offer two ways.
Option 1: use the withSupabase wrapper, which handles CORS and OPTIONS requests for you:
import { withSupabase } from 'npm:@supabase/server@^1'
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
const { name } = await req.json()
return Response.json({ message: `Hello ${name}!` })
}),
}
Option 2: handle it yourself. With @supabase/supabase-js v2.95.0 or later, import the header list from the SDK so it stays in sync with what the client sends:
import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors'
export default {
fetch: async (req) => {
if (req.method === 'OPTIONS') {
return Response.json({ ok: true }, { headers: corsHeaders })
}
try {
const { name } = await req.json()
return Response.json({ message: `Hello ${name}!` }, { headers: corsHeaders })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return Response.json({ error: message }, { status: 400, headers: corsHeaders })
}
},
}
Note that the error response carries corsHeaders too. If an error path returns without them, the browser reports a CORS error and hides your real error message.
On older SDK versions, Supabase’s docs say to hard-code the headers in supabase/functions/_shared/cors.ts, covering every header your clients send:
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, x-retry-count, traceparent, tracestate, baggage',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
}
The supabase-js client sends authorization, apikey and x-client-info on every request, so a list that only has content-type fails the preflight. If you upgrade the SDK in your front-end and calls suddenly fail, upgrade it in the function and redeploy, so the function’s allow-list matches the headers the client now sends.
Fix it in an Express API
Install the cors middleware (npm install cors) and register it before your routes. Used app-wide, it also answers preflight requests for all routes:
const cors = require('cors')
app.use(cors({
origin: ['https://yourapp.netlify.app', 'http://localhost:5173'],
credentials: true, // only if you send cookies
}))
With no options, cors() sends Access-Control-Allow-Origin: *. That’s fine for a public, read-only API. For a private API, list your origins. When you set a specific origin, the middleware also adds Vary: Origin for you.
Cookies: never *
If the request sends credentials (cookies, or fetch(url, { credentials: 'include' })), MDN says the server must reply with an explicit origin, not *, in Access-Control-Allow-Origin, and must also avoid * in Access-Control-Allow-Headers and Access-Control-Allow-Methods. With *, the browser blocks the response and won’t set any cookie from Set-Cookie. Send the exact origin, Access-Control-Allow-Credentials: true, and Vary: Origin when the value depends on the request.
Gotchas
- The origin must match exactly.
https://yourapp.comandhttps://www.yourapp.comare different origins, and so arehttp://andhttps://. Add each domain you actually serve from, including your custom domain once you add one. - Don’t leave only localhost. Code written in a builder’s preview often allows
http://localhost:5173only. Add the production URL before you deploy. - Don’t fix it with
mode: 'no-cors'. MDN notes the response is then opaque (unreadable), so your app can’t use the data. - No server control? If you call a third-party API that doesn’t send CORS headers, MDN’s advice is to route the call through a proxy you control (for example, a serverless function on your host). That’s also where secret API keys belong, not in the browser.
Check it worked
Deploy both sides, open the production front-end, and watch the Network tab. You should see the OPTIONS request (if any) return 200 or 204 with Access-Control-Allow-Origin set to your origin, followed by the real request succeeding. You can also check the preflight from a terminal:
curl -i -X OPTIONS https://api.example.com/items \
-H "Origin: https://yourapp.netlify.app" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization, content-type"
The response headers should include Access-Control-Allow-Origin and list the method and headers you asked for.