CORS Errors Haunting You? Here's the Decision Tree That Finally Works
Your staging server talks to your API. Everything works. Ship to production and suddenly: Access to XMLHttpRequest at 'https://api.yoursite.com' from origin 'https://yoursite.com' has been blocked by CORS policy.
You didn’t change the code. The code is identical. But now it’s screaming about CORS.
This is where most indie builders lose two hours.
Why CORS Exists (The 30-Second Version)
Browsers enforce CORS (Cross-Origin Resource Sharing) to stop malicious websites from stealing your data. If a rogue site could freely hit your API, attackers could drain wallets, steal PII, exfiltrate databases—the usual nightmare. CORS is the bouncer. It says: “Who’s asking?”
The problem: you often ARE the one asking, and the bouncer doesn’t recognize you.
The Decision Tree That Actually Works
Before you fumble around with headers, ask three questions:
1. Is the request even crossing origins? - Same protocol (http vs https)? Same domain (app.site.com vs api.site.com)? Same port (8000 vs 8001)? - If ANY differ → you have a CORS issue. If all match → CORS isn’t your problem (check console for real error).
2. Is this a preflight request (OPTIONS)? - Browsers automatically fire an OPTIONS request before the real POST/PUT/DELETE. - If your server doesn’t respond to OPTIONS, preflight fails, real request never fires. - Fix: Your backend must respond to OPTIONS with the right headers.
3. Does your backend send the right headers?
Access-Control-Allow-Origin: https://yourfrontend.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
If even one is wrong or missing → CORS block. No exceptions.
Scenario 1: Localhost Development (Port Mismatch)
Frontend on http://localhost:3000, API on http://localhost:3001.
Why it breaks: Different ports = different origins.
Fix:
- Option A: Proxy requests through Next.js rewrites or Vite middleware (localhost:3000/api/... → localhost:3001/...). Zero CORS drama.
- Option B: Add Access-Control-Allow-Origin: http://localhost:3000 to your backend.
Pro tip: Option A is cleaner. One domain, zero CORS. This is why Next.js API routes exist.
Scenario 2: Staging + Production Mismatch
Staging works because both frontend and API are on the same domain (subdomains count as cross-origin). Production breaks because the domain changed or you’re using a CDN.
Why it breaks: Your backend’s Access-Control-Allow-Origin header is hardcoded to the old domain.
Fix: Make the header dynamic.
Instead of hardcoding, whitelist origins:
const allowedOrigins = ['https://yoursite.com', 'https://staging.yoursite.com', 'http://localhost:3000'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
}
Scenario 3: Third-Party APIs (You Can’t Fix Their CORS)
You call Stripe, Twilio, or a third-party API from the browser. They didn’t set Access-Control-Allow-Origin: *, and your request dies.
Why it breaks: Their server doesn’t allow browser requests from your domain.
Fix: Don’t call it from the browser. Call it from your backend.
Bad: fetch('https://api.stripe.com/...') from React.
Good: fetch('/api/stripe-charge') from React → backend calls Stripe.
Your backend isn’t subject to CORS. It can talk to anyone.
Scenario 4: Credentials and Cookies
You’re sending auth tokens with credentials: 'include' in fetch, but the API doesn’t set Access-Control-Allow-Credentials: true.
Why it breaks: Browsers block credentialed requests unless the server explicitly says yes.
Fix: Add the header and make Access-Control-Allow-Origin specific (not *).
If you use Access-Control-Allow-Origin: * with credentials, browsers reject it. Catch-22, but intentional.
The Debug Checklist
- Open DevTools Network tab. Find the failed request.
- Click it. Look at Response Headers. Is
Access-Control-Allow-Originpresent? - Does it include your frontend’s origin?
- Is it an OPTIONS request? Check that the 200 response has the CORS headers.
- Is the request POST/PUT/DELETE with custom headers or
Content-Type: application/json? Make sureAccess-Control-Allow-Headersincludes them.
Missing even one header = blocked request.
When This Gets Messy
CORS is simple in theory. In practice, misconfigured CORS haunts production deployments, staging/prod divergence, and third-party integrations. You diagnose the same issue three times across different code paths, each with a different root cause.
If your API spans multiple services, multiple teams, or dynamically generated origins, CORS hygiene becomes a whole project. And when you do get it wrong in production, it’s visible to users—not a quiet bug, but a breaking feature.
This is where a structured, engineer-led approach to your backend architecture pays off. Whether you’re building the API yourself or partnering with specialists, getting CORS right at inception (along with auth, error handling, and versioning) saves hours of firefighting later.
If you’re shipping a product that touches sensitive user data or integrates with external services, and CORS feels like it keeps biting you, it might be time to think about your backend strategy more holistically. Trove Deck Solution works with SaaS founders and indie builders to design backends that scale without breaking—API security, CORS, auth, the whole stack—and ship it right. Let’s talk.