Skip to main content
Logo
Overview

Fix CORS Errors: Next.js, Express, FastAPI, Django

May 22, 2026
9 min read

You shipped the frontend, you shipped the API, and then the console lights up red: Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

It always happens at the worst time. The code worked five minutes ago when everything was on localhost, and now that the two halves live on different domains, the browser slams the door. If you’ve copy-pasted Access-Control-Allow-Origin: * and moved on, you’ve probably also hit the follow-up error where credentials stop working — because that wildcard is a trap in disguise.

Let me walk through what’s actually happening and the right fix for each stack. Not the symptom-killer. The fix that won’t bite you in production.

The browser is blocking you, not your server

Here’s the thing most people get backwards. CORS errors are not your server rejecting the request. Your server happily processed it and sent a response. The browser then looked at that response, didn’t find permission to share it across origins, and refused to hand the data to your JavaScript.

An “origin” is the triple of scheme + host + port. https://app.example.com and https://api.example.com are different origins. So are http://localhost:3000 and http://localhost:8000. Same machine, different port, still cross-origin. That’s why your local setup with separate frontend and backend ports triggers it too.

The Same-Origin Policy is the browser’s default: scripts can only read responses from their own origin. CORS is the controlled exception. The server opts in by sending response headers that say “yes, this origin is allowed to read me.” No header, no read. The request still went through — which is why a POST that “failed” with CORS may have actually created the record on your backend. Worth remembering before you retry it five times.

So when you see “No ‘Access-Control-Allow-Origin’ header,” read it literally: your server didn’t include the permission slip. The fix is always on the server.

Preflight, credentials, and the headers that matter

Simple requests (a plain GET, or a POST with a basic content type like text/plain or a form encoding) go straight to the server. The browser checks the response headers after the fact.

Anything more interesting — a PUT or DELETE, a Content-Type: application/json, a custom header like Authorization — triggers a preflight. Before the real request, the browser fires an OPTIONS request asking “am I allowed to send this?” Your server has to answer that OPTIONS with the right headers, or the real request never leaves the browser.

The headers in play:

  • Access-Control-Allow-Origin — which origin can read the response. Either a specific origin or *.
  • Access-Control-Allow-Methods — which HTTP methods are permitted (answer to preflight).
  • Access-Control-Allow-Headers — which request headers are permitted (answer to preflight).
  • Access-Control-Allow-Credentials — whether cookies/auth headers are allowed.
  • Access-Control-Max-Age — how long the browser can cache the preflight result, so it stops asking on every call.

The one rule that catches everyone: you cannot use Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true. The spec forbids it. If your requests send cookies or an Authorization header and you’ve got the wildcard, the browser rejects the response even though the server “allowed” it. You must echo back the specific requesting origin instead. Keep that in your back pocket — it explains half the “but I already set the header!” confusion.

Next.js: route handlers, middleware, and the proxy trick

If your API lives in the same Next.js app as your frontend, you usually don’t have a CORS problem at all — same origin. CORS shows up when an external client hits your route handlers, or when your frontend calls a separately-hosted API.

For a single route handler, set the headers on the response and export an OPTIONS handler for preflight:

// app/api/data/route.ts
const ALLOWED = "https://app.example.com";
 
export async function GET(request: Request) {
  return Response.json(
    { hello: "world" },
    {
      headers: {
        "Access-Control-Allow-Origin": ALLOWED,
        "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
      },
    }
  );
}
 
export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": ALLOWED,
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
      "Access-Control-Max-Age": "86400",
    },
  });
}

One nice detail in current Next.js: if you don’t define OPTIONS yourself, the framework auto-implements it and sets an Allow header based on the methods you did define. That’s fine for the basic case, but it won’t add your CORS headers — so for anything credentialed you still want the explicit handler above.

Repeating those headers across twenty route files gets old fast. For app-wide CORS, push it into middleware (recently surfaced as the proxy.ts convention in Next.js 16, still middleware.ts in 15) so one place handles every matched route and short-circuits preflight:

// middleware.ts  (or proxy.ts on Next.js 16)
import { NextResponse } from "next/server";
 
export function middleware(request: Request) {
  const origin = request.headers.get("origin") ?? "";
  const allowList = ["https://app.example.com", "http://localhost:3000"];
  const allowOrigin = allowList.includes(origin) ? origin : "";
 
  if (request.method === "OPTIONS") {
    return new NextResponse(null, {
      status: 204,
      headers: {
        "Access-Control-Allow-Origin": allowOrigin,
        "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
        "Access-Control-Allow-Credentials": "true",
      },
    });
  }
 
  const response = NextResponse.next();
  response.headers.set("Access-Control-Allow-Origin", allowOrigin);
  response.headers.set("Access-Control-Allow-Credentials", "true");
  return response;
}
 
export const config = { matcher: "/api/:path*" };

Notice I’m checking the incoming origin against an allow-list and echoing it back, not blasting *. That’s what makes credentials work. The matcher keeps the logic off your pages.

Express: just use the cors package, but configure it

Don’t hand-roll header logic in Express. The cors package has been the answer for a decade and it handles preflight for you.

const express = require("express");
const cors = require("cors");
const app = express();
 
app.use(
  cors({
    origin: ["https://app.example.com", "http://localhost:3000"],
    methods: ["GET", "POST", "PUT", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: true,
  })
);

The mistake I see constantly is app.use(cors()) with no options. That defaults to origin: * and doesn’t enable credentials, so the moment you add cookie-based auth, it breaks — and you’re back to square one wondering why the wildcard “stopped working.” Pass an explicit origin array or a function, and set credentials: true if you need cookies.

If preflight specifically is failing, make sure cors is registered before your routes and that nothing (like a body parser throwing on an empty OPTIONS body) runs ahead of it. Order matters in Express middleware, and CORS belongs near the top.

FastAPI: CORSMiddleware is restrictive on purpose

FastAPI pulls CORSMiddleware from Starlette, and its defaults are deliberately locked down — empty origins, only GET allowed. You have to opt in to everything.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
 
app = FastAPI()
 
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

The middleware handles preflight OPTIONS automatically, so you don’t write a separate handler. But the same wildcard rule applies under the hood: if you set allow_credentials=True, you can’t use allow_origins=["*"]. Starlette’s implementation will refuse to reflect the credentialed wildcard, and you’ll stare at a missing header wondering what went wrong. List your origins explicitly.

A subtle one that wastes afternoons: if an unhandled exception fires before the response is built, the CORS headers may not get attached, and you’ll see a CORS error masking a 500. When a request fails with CORS only on the error path, check your server logs — the real problem is usually an exception, not CORS.

Django: django-cors-headers, in the right order

Don’t try to set CORS headers manually in Django views. Install django-cors-headers and wire it up:

# settings.py
INSTALLED_APPS = [
    # ...
    "corsheaders",
]
 
MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",  # as high as possible
    "django.middleware.common.CommonMiddleware",
    # ...
]
 
CORS_ALLOWED_ORIGINS = [
    "https://app.example.com",
    "http://localhost:3000",
]
 
CORS_ALLOW_CREDENTIALS = True

The placement is not optional. CorsMiddleware has to sit above CommonMiddleware (and above anything that might generate a response, like Django’s GZipMiddleware), or the headers get added too late and the browser never sees them. Most “I installed the package and it still doesn’t work” cases come down to middleware order.

Resist the temptation of CORS_ALLOW_ALL_ORIGINS = True. It’s the Django equivalent of the wildcard, and it can’t coexist with credentialed requests in any sane way. Use the allow-list.

The cleaner option: make CORS disappear with a proxy

Here’s a take that’ll save you a lot of header-fiddling: in a lot of cases, the best CORS fix is to not have cross-origin requests at all.

If your frontend talks to the backend through the same origin — because a proxy forwards /api/* to the backend — there’s no cross-origin call, no preflight, no headers to maintain. The browser sees one origin.

In Next.js, rewrites do this:

// next.config.js
module.exports = {
  async rewrites() {
    return [
      { source: "/api/:path*", destination: "https://api.internal.example.com/:path*" },
    ];
  },
};

In production, an Nginx reverse proxy does the same thing — serve the frontend and proxy /api/ to the backend under one hostname. Vite’s dev server has server.proxy for local work. The browser only ever sees its own origin, so CORS is irrelevant.

I lean toward the proxy approach whenever the frontend and backend are deployed together, because it kills an entire category of bug. The downside: it adds a hop, and if your API is genuinely public (consumed by third-party clients on other domains), a proxy doesn’t help them — those clients still need real CORS headers on your API. Pick based on who’s actually calling you.

Mistakes that keep people stuck

A few patterns show up over and over:

  • Wildcard plus credentials. Covered above, but it’s the number one cause. * and Allow-Credentials: true are mutually exclusive. Echo the specific origin.
  • Forgetting OPTIONS. If preflight isn’t answered, the real request never fires. Frameworks that auto-handle it (FastAPI, the cors package) hide this; raw handlers don’t.
  • Fixing it client-side. Browser extensions or fetch flags that “disable CORS” only work on your machine. They fix nothing for your users. The change belongs on the server.
  • A proxy in the way. A CDN, API gateway, or load balancer can strip or override your CORS headers. If your app code looks correct but headers vanish, inspect the response in the Network tab and trace where they disappear.

That last one is why you should always check the actual response headers in DevTools rather than trusting your config. The Network tab tells you the truth about what the browser received.

What to lock down before you ship

In development, a permissive setup is fine. In production, treat your Allow-Origin list like a guest list — only the origins you actually serve. Pull them from environment variables so staging and prod differ without code changes, and never let a public API reflect any origin back with credentials enabled. That combination effectively turns off the protection CORS exists to provide.

Next time the red text shows up, don’t reach for the wildcard. Read which header is missing, decide whether you even need cross-origin requests or could proxy them away, and apply the fix on the server side for your stack. Then go check the Network tab to confirm the header is actually there — because the config saying so and the browser seeing it are two different things.


Sources: