CORS in Single-Page Applications

If you've never had a CORS problem, you've probably never built a single-page application. SPAs are the number one generator of CORS confusion in the known universe, and it's not even close. The architecture practically guarantees you'll run into it on day one.

Let's talk about why, and then let's fix it—permanently.

Why SPAs Are Ground Zero for CORS

Traditional server-rendered applications don't have CORS problems. Your Django template, your Rails view, your PHP page—they all make requests back to the same server that rendered them. The browser loaded the page from https://app.example.com, and every form submission and AJAX call goes right back to https://app.example.com. Same origin. No CORS. Life is simple.

SPAs broke this model. Now your frontend is a static bundle of JavaScript that runs entirely in the browser, making API calls to a backend that may live at a completely different origin. The browser sees JavaScript loaded from one origin making fetch() calls to another origin, and the Same-Origin Policy kicks in.

But here's the part that really gets people: the problem usually shows up in development first, in a way that's confusing enough to send you down the wrong rabbit hole.

The Classic Setup: Dev Server on :3000, API on :8080

Every SPA framework ships with a development server. React (via Vite or Create React App), Vue, Angular, Svelte—they all give you a hot-reloading dev server that typically runs on http://localhost:3000 or http://localhost:5173.

Your API server runs on a different port. Maybe it's Express on :8080, Django on :8000, or Spring Boot on :9090.

Here's your frontend code:

// Running on http://localhost:5173
const response = await fetch('http://localhost:8080/api/users');
const users = await response.json();

You open your browser, navigate to http://localhost:5173, and immediately see this in the DevTools console:

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

Different ports mean different origins. http://localhost:5173 and http://localhost:8080 are not the same origin. The browser is doing exactly what it's supposed to do.

This is the moment where roughly 50% of developers add Access-Control-Allow-Origin: * to their API server and call it a day. The other 50% google "CORS error fix" and end up on a Stack Overflow answer from 2016 that tells them to install a browser extension that disables CORS.

Neither of these is what you want. Let me show you the right approaches.

The Dev Proxy: Making the Browser Think It's Same-Origin

Every major frontend build tool supports a development proxy, and it's the cleanest solution for local development. The idea is simple: instead of having your frontend JavaScript make cross-origin requests directly to http://localhost:8080, you configure the dev server to proxy those requests. Your frontend makes requests to its own origin, and the dev server forwards them to your API behind the scenes.

The browser never sees a cross-origin request. No CORS headers needed.

Vite Proxy Configuration

In vite.config.js:

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    }
  }
})

Now your frontend code changes to:

// Running on http://localhost:5173
// This request goes to http://localhost:5173/api/users
// Vite proxies it to http://localhost:8080/api/users
const response = await fetch('/api/users');
const users = await response.json();

The browser sees a request from http://localhost:5173 to http://localhost:5173/api/users. Same origin. CORS never enters the picture.

webpack-dev-server (Create React App, older setups)

In webpack.config.js:

module.exports = {
  devServer: {
    proxy: [
      {
        context: ['/api'],
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    ]
  }
}

Or if you're using Create React App, just add this to package.json:

{
  "proxy": "http://localhost:8080"
}

That one line proxies all unrecognized requests to your API server. It's blunt but effective.

Angular CLI

In proxy.conf.json:

{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false,
    "changeOrigin": true
  }
}

Then start the dev server with:

ng serve --proxy-config proxy.conf.json

Why the Dev Proxy Works

Let's be precise about what's happening. Without the proxy:

Browser (localhost:5173) ----> API Server (localhost:8080)
  Origin: http://localhost:5173
  → Cross-origin request → CORS applies → blocked (no CORS headers)

With the proxy:

Browser (localhost:5173) ----> Dev Server (localhost:5173) ----> API Server (localhost:8080)
  Origin: http://localhost:5173     (server-to-server, no browser)
  → Same-origin request            → No CORS at all
  → CORS never applies

The key insight: CORS is a browser enforcement mechanism. The dev server's proxy is a Node.js process making an HTTP request to your API. There's no browser involved in that second hop. Node.js doesn't enforce the Same-Origin Policy. The proxy fetches the response and hands it back to the browser as if it came from the dev server itself.

This is not a hack. This is not "disabling" CORS. This is genuinely making the request same-origin from the browser's perspective.

Development vs. Production: Two Different Worlds

Here's what catches people: the dev proxy is a development tool. It doesn't exist in production. When you run npm run build, you get a folder of static files. There's no Vite, no webpack-dev-server, no proxy. You need a real strategy for production.

This leads to an architecture decision that you should make before you start building, not after you've deployed and everything's on fire.

Production Architecture #1: Reverse Proxy (No CORS Needed)

The most common production setup—and the one I recommend for most applications—is to serve everything from the same origin using a reverse proxy.

                            ┌──────────────────────┐
                            │   Nginx / Caddy /    │
   Browser ──────────────►  │   Cloud Load Balancer │
   https://app.example.com  │                      │
                            │  /           → Static │
                            │  /api/*      → API   │
                            └──────────────────────┘

The browser only ever talks to https://app.example.com. Nginx routes /api/* to your backend and everything else to your static files. Same origin. No CORS. This mirrors what your dev proxy was doing, but in production.

Here's a minimal Nginx config:

server {
    listen 443 ssl;
    server_name app.example.com;

    # Serve the SPA static files
    location / {
        root /var/www/app/dist;
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to the backend
    location /api/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

This is the "just don't have CORS" strategy, and it's beautiful in its simplicity. Your frontend code uses relative URLs (/api/users), and it works identically in development (via the dev proxy) and production (via Nginx).

Production Architecture #2: Separate API Domain (CORS Required)

Sometimes you genuinely need your API on a different domain. Common reasons:

  • Your API serves multiple frontends (web app, mobile app, partner integrations)
  • You're using a managed API service (AWS API Gateway, Cloudflare Workers)
  • Your frontend is on a CDN at app.example.com and your API is at api.example.com
  • Organizational reasons: the API team and frontend team deploy independently

In this case, you need real CORS configuration on your API server. Your frontend code uses absolute URLs:

const API_BASE = 'https://api.example.com';
const response = await fetch(`${API_BASE}/api/users`, {
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
});

And your API server responds with proper CORS headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

Note that we're returning the specific origin, not *. That matters, especially if you're dealing with credentials.

Authentication is where CORS configuration goes from "slightly annoying" to "pull your hair out." The approach you choose for auth has a dramatic impact on your CORS complexity.

Bearer Tokens (JWT, API Keys)

With token-based auth, the token lives in JavaScript-accessible storage (usually localStorage or memory) and is sent as a header:

fetch('https://api.example.com/api/users', {
  headers: {
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
  },
});

CORS implications:

  1. The Authorization header is not a CORS-safelisted header. This means every single authenticated request triggers a preflight OPTIONS request.
  2. You don't need credentials: 'include' because you're not sending cookies.
  3. You can use Access-Control-Allow-Origin: * if your API is truly public (but you probably shouldn't for authenticated endpoints).

Here's the preflight exchange:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

The Access-Control-Max-Age: 86400 is critical here. Without it, the browser sends a preflight before every API call. With it, the browser caches the preflight result for 24 hours. This is the difference between your API getting double the traffic and not.

Let's verify this with curl. First, the preflight:

curl -v -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: Authorization"

Then the actual request:

curl -v https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

With cookie-based auth, the session cookie is managed by the browser and sent automatically. This is trickier with CORS:

fetch('https://api.example.com/api/users', {
  credentials: 'include',  // Required to send cookies cross-origin
});

CORS implications:

  1. You must set credentials: 'include' on every request.
  2. The server must respond with Access-Control-Allow-Credentials: true.
  3. The server cannot use Access-Control-Allow-Origin: *. It must echo back the specific origin.
  4. The server cannot use Access-Control-Allow-Headers: * or Access-Control-Allow-Methods: *.
  5. Cookies must have SameSite=None; Secure to be sent cross-origin in modern browsers.

The response headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Set-Cookie: session=abc123; Path=/; Secure; HttpOnly; SameSite=None

Cookie-based auth with CORS is the hardest configuration to get right. If you have the choice, bearer tokens are simpler for cross-origin architectures. If you're using a reverse proxy (same origin), cookies work great with zero CORS headaches.

Authorization Header Always Triggers Preflight

This is worth its own section because it surprises so many people.

You might think a simple GET request with an Authorization header is a "simple request." It's not. The CORS spec considers only a handful of headers as safelisted: Accept, Accept-Language, Content-Language, and Content-Type (with restrictions). Authorization is not among them.

This means that even this:

fetch('https://api.example.com/api/public-data', {
  headers: { 'Authorization': 'Bearer token123' }
});

...triggers a preflight. Every time (unless cached). For an API that handles hundreds of requests per second, that's potentially hundreds of extra OPTIONS requests per second.

Mitigations:

  1. Set Access-Control-Max-Age aggressively. 86400 (24 hours) is the maximum Chrome will honor. Firefox allows up to 86400 as well. Safari caps it at a frustrating 600 seconds (10 minutes). Set it to 86400 and accept that Safari users generate more preflights.

  2. Consider using the reverse proxy approach if your frontend and API can share an origin. No CORS, no preflights, no overhead.

  3. Don't fight the spec. Some developers try to work around this by putting the token in a query parameter or a cookie instead. Query parameters are a security hazard (they end up in logs, referrer headers, and browser history). Cookies work but bring their own complexity. Just handle the preflight properly and cache it.

Practical Setup: React + Express

Here's a complete working setup. The file structure:

my-app/
├── client/          # React (Vite)
│   ├── src/
│   │   └── App.jsx
│   └── vite.config.js
└── server/          # Express
    └── index.js

server/index.js:

const express = require('express');
const cors = require('cors');
const app = express();

// CORS config: only needed if not using reverse proxy
app.use(cors({
  origin: 'http://localhost:5173',  // Vite dev server
  credentials: true,
  maxAge: 86400,
}));

app.use(express.json());

app.get('/api/users', (req, res) => {
  res.json([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
});

app.listen(8080, () => console.log('API on :8080'));

client/vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      }
    }
  }
});

client/src/App.jsx:

import { useEffect, useState } from 'react';

function App() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    // Relative URL — works with both dev proxy and production reverse proxy
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers);
  }, []);

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

Notice that the React code uses /api/users, not http://localhost:8080/api/users. This is intentional. The same code works in development (proxied by Vite) and production (proxied by Nginx). You never hardcode the API origin in your frontend.

Practical Setup: Vue + Django

Django settings.py (using django-cors-headers):

INSTALLED_APPS = [
    # ...
    'corsheaders',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # Must be high in the list
    'django.middleware.common.CommonMiddleware',
    # ...
]

# Development
CORS_ALLOWED_ORIGINS = [
    'http://localhost:5173',
]

# If using cookie-based auth (Django sessions)
CORS_ALLOW_CREDENTIALS = True

vite.config.js (Vue uses Vite too):

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      }
    }
  }
})

With the proxy configured, you don't even need django-cors-headers during development. The proxy handles it. You only need the CORS configuration for production if your API is on a separate domain.

Practical Setup: Next.js API Routes

Next.js is interesting because it can sidestep CORS entirely. If you use Next.js API routes, your API handlers run on the same origin as your frontend:

// app/api/users/route.js (Next.js App Router)
export async function GET() {
  const users = await db.query('SELECT * FROM users');
  return Response.json(users);
}
// In your component or client code
const res = await fetch('/api/users');

Same origin. No CORS. This is the ultimate "avoid CORS" strategy—put your API in your frontend framework.

But if your Next.js app calls an external API from the client side, you're back to CORS. The common pattern is to use Next.js API routes as a BFF (Backend for Frontend) that proxies to external services:

// app/api/users/route.js — acts as a proxy
export async function GET(request) {
  // Server-side fetch: no CORS restrictions
  const res = await fetch('https://api.internal.example.com/users', {
    headers: {
      'Authorization': `Bearer ${process.env.API_KEY}`,
    },
  });
  const data = await res.json();
  return Response.json(data);
}

The client calls /api/users (same origin). The Next.js server calls api.internal.example.com (server-to-server, no browser, no CORS). You get to keep your API keys on the server too. Everyone wins.

Environment-Based Configuration

A pattern I see in well-maintained SPAs: the API base URL comes from environment variables, and the dev proxy makes the default case "just work."

// config.js
export const API_BASE = import.meta.env.VITE_API_BASE || '';
// usage
fetch(`${API_BASE}/api/users`);

In development, VITE_API_BASE is not set, so it defaults to empty string, and requests go to the same origin (proxied by Vite). In production with a reverse proxy, same thing. In production with a separate API domain, you set VITE_API_BASE=https://api.example.com at build time.

DevTools: How to Confirm CORS Is Working

Open Chrome DevTools, go to the Network tab, and make your API request. Click on the request and look at:

  1. Request Headers: Look for the Origin header. If it's present, the browser considered this a cross-origin request. If it's absent, the request is same-origin (proxy is working).

  2. Response Headers: Look for Access-Control-Allow-Origin. If it matches your origin, CORS is properly configured. If it's missing, the server isn't sending CORS headers.

  3. Preflight requests: Filter by "Method: OPTIONS" in the Network tab. If you see OPTIONS requests before your actual requests, preflights are happening. If you don't see them, either the requests are simple requests or they're same-origin.

  4. The "(cors)" label: Chrome shows the request's initiator type. Cross-origin fetch requests show as "fetch" with CORS context. If you see a request failing with "(cors error)", the response headers are missing or incorrect.

To see the full picture, check "Disable cache" in DevTools. Preflight caching can hide issues during development.

Summary

The SPA CORS playbook:

PhaseStrategyCORS needed?
DevelopmentDev server proxy (Vite, webpack)No
Production (simple)Reverse proxy (Nginx, Caddy)No
Production (separate API domain)Proper CORS headers on APIYes
Next.js / full-stack frameworkAPI routes on same originNo

Use relative URLs in your frontend code (/api/users, not http://localhost:8080/api/users). This makes the same code work across all environments.

If you must use CORS in production, configure it properly on the server, set Access-Control-Max-Age, and test with curl before blaming the browser. The next two chapters cover exactly how to do that with API gateways and server frameworks.