Skip to main content
Browser window showing fetch blocked by CORS next to bold CORS Fix in Frontend text

How to Fix CORS Error in Frontend: Practical Guide

You call fetch('https://api.example.com/users'), expecting JSON, and instead you get this:

Access to fetch at 'https://api.example.com/users' from origin
'http://localhost:4321' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

If you’ve run into this, you know how frustrating it feels. Your code looks fine, the API works in Postman, but the browser just says no. Here’s why that happens and how to actually fix it.

What CORS Actually Is

CORS (Cross-Origin Resource Sharing) isn’t a frontend bug. It’s the browser trying to protect your users, built on top of something called the Same-Origin Policy.

Same origin just means same protocol + domain + port:

http://localhost:4321 -> https://api.example.com = cross-origin
https://app.com -> https://api.app.com = cross-origin

By default, browsers won’t let JavaScript read responses from a different origin. The server has to explicitly say “yeah, I allow this” with headers like:

Access-Control-Allow-Origin: https://app.com

Here’s the part that trips everyone up: your request usually does reach the server. The browser just refuses to hand you the response.

Diagnose First: Simple vs Preflight

Before you change anything, open DevTools, head to the Network tab, and look for the failed request — plus any OPTIONS request next to it.

  1. Simple request: GET / POST with standard headers. The browser just sends it and then checks Access-Control-Allow-Origin on the way back.

  2. Preflighted request: PUT, DELETE, an Authorization header, or Content-Type: application/json. The browser first sends an OPTIONS check:

OPTIONS /users HTTP/1.1
Origin: http://localhost:4321
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

Your server then needs to respond with something like:

Access-Control-Allow-Origin: http://localhost:4321
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

If that OPTIONS check fails, your actual request never even gets sent.

Fix 1: Fix the Backend (That’s Where It Lives)

Here’s the truth: CORS is fixed on the server, not in your frontend code. If you own the API, that’s where you should fix it.

With Express and the cors package:

import cors from 'cors';
import express from 'express';

const app = express();

app.use(cors({
  origin: ['https://pahwandi.com', 'http://localhost:4321'],
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}));

Plain Node / Cloudflare Worker style:

return new Response(JSON.stringify(data), {
  headers: {
    'Access-Control-Allow-Origin': 'https://pahwandi.com',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  },
});

Three gotchas I’ve seen bite people over and over:

  • Access-Control-Allow-Origin: * doesn’t work with credentials: include. You’ve got to echo back a specific origin.
  • You’ve got to list every custom header you send in Allow-Headers, or the preflight will fail.
  • Handle OPTIONS fast with a 204. No auth check, no DB query there.

Fix 2: Use a Vite / Astro Proxy for Local Dev

If you’re building on localhost:4321 against a production API, you’ll hit CORS constantly. Don’t add localhost to your prod CORS config — use a proxy instead, trust me.

In astro.config.mjs (it’s Vite under the hood):

export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
};

Then in your code:

// dev: hits localhost:4321/api/users -> proxied, same-origin, no CORS
// prod: use real URL via env
const res = await fetch(`${import.meta.env.PUBLIC_API_URL}/users`);

Now the browser sees a same-origin request. No preflight headaches. This is how most of us handle local dev.

Fix 3: The Credentials Gotcha

Cookies and sessions make this trickier:

await fetch('https://api.example.com/me', {
  credentials: 'include', // send cookies
  headers: { Authorization: 'Bearer xxx' },
});

Your server then has to send back both of these:

Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Credentials: true

Not * — a specific origin plus Allow-Credentials: true. Miss either one and the browser blocks you with a pretty confusing error about wildcards.

What Please Don’t Do

I’ve tried all of these shortcuts. They don’t work:

  1. Don’t use mode: 'no-cors'. You’ll get back an opaque response you can’t even read. It just hides the error, it doesn’t fix anything.

  2. Don’t ask users to disable browser security or install a CORS extension. Sure, it works on your machine, but it’ll break everywhere else.

  3. Don’t use a public CORS proxy in production. You’re leaking tokens through a stranger’s server, plus adding latency. Fine for a 2-minute test, never for prod.

  4. Don’t set Access-Control-Allow-Origin: * on an API with auth. You’re opening the door to session hijacking. Echo the origin instead.

Quick Checklist

Still stuck? Run through this:

  • What origin does the error show? Copy the exact protocol + port.
  • Is there an OPTIONS request? What’s its status and headers?
  • Does the response include an Allow-Origin matching your page — not * when you’re using credentials?
  • Are Authorization and Content-Type listed in Allow-Headers?
  • Using cookies? Did you set both credentials: include and Allow-Credentials: true?
  • On local dev? Use the Vite proxy instead of loosening prod CORS.

Final Thoughts

CORS feels like your fault because it shows up in your console, but it isn’t. The browser is just doing its job — stopping random sites from reading other sites’ data.

The fix lives on the server: send the right headers for the right origin. Use a proxy when you’re developing locally. And skip the hacks.

Next time you see that red error, check the Network tab for OPTIONS, fix the header on the backend, and get on with your day.

References