Skip to content

Fixing CORS errors in React and Vite, in dev and in production

Fix CORS errors in a React app built with Vite: set up server.proxy for development, understand why it vanishes in production, and choose a production fix.

The ProxifyEdge team 5 min read

You’re building a React app with Vite. You call an API with fetch or Axios, and the console fills with CORS errors. Then you find the server.proxy option, everything works, and you deploy. In production, the errors are back, or every API call returns your index.html. This guide shows how to fix CORS errors in React and Vite properly: the dev proxy for local work, why it disappears in production, and the production options that actually work.

The examples use React, but nothing here is React-specific. The same applies to Preact, Solid or plain TypeScript on Vite.

Why a React app hits CORS errors

A Vite dev server runs your app on http://localhost:5173. When your component calls https://api.example.com, that’s a cross-origin request, and the browser only lets your code read the response if the API sends Access-Control-Allow-Origin allowing http://localhost:5173.

React has nothing to do with it. The error comes from the browser enforcing the same-origin policy, and the fix belongs to whichever server answers the request. If the error message is unfamiliar, how to fix ‘No Access-Control-Allow-Origin header is present’ explains each part of it.

That leaves two questions: what to do in development, and what to do in production. They have different answers.

In development: Vite’s server.proxy

Vite’s dev server can forward requests to another host. Your code calls a relative path on the dev server’s origin, Vite forwards it server-side, and the browser never sees a cross-origin request:

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

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

In your component, call the relative path:

import { useEffect, useState } from 'react';

type Item = { id: number; name: string };

export function Items() {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {
    const controller = new AbortController();
    fetch('/api/v1/items', { signal: controller.signal })
      .then((response) => {
        if (!response.ok) throw new Error(`Request failed: ${response.status}`);
        return response.json();
      })
      .then(setItems)
      .catch((error) => {
        if (error.name !== 'AbortError') console.error(error);
      });
    return () => controller.abort();
  }, []);

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

What each option does:

  • target: where requests matching /api are sent.
  • changeOrigin: true: rewrites the Host header to the target’s host. Many servers, and anything behind a CDN or virtual hosting, reject requests whose Host says localhost.
  • rewrite: strips the /api prefix if the real API doesn’t have one.

Vite uses the same proxy for vite preview by default, so a local production build behaves like development. If you’re still on Create React App, which is now deprecated, the proxy field in package.json does the same job.

Keep the base URL configurable

Hard-coding /api in components ties them to the dev proxy. Read the base URL from an environment variable instead, so the same code can talk to a different host in production:

// src/api.ts
const BASE = import.meta.env.VITE_API_BASE ?? '/api';

export async function getItems() {
  const response = await fetch(`${BASE}/v1/items`);
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);
  return response.json();
}

Only variables prefixed with VITE_ are exposed to client code, and they’re inlined into the bundle at build time. Never put a secret in a VITE_ variable. Anything in it is readable by every visitor. See why you can’t hide an API key in frontend code.

Why the proxy disappears in production

vite build produces static files: HTML, JavaScript and CSS. There’s no Vite server in production, so there’s no proxy. Requests to /api/... go to whatever hosts your static files, which usually:

  • returns 404, or
  • returns your index.html because of a single-page-app fallback rule. Your code then tries to parse HTML as JSON and fails with Unexpected token '<'.

If you see Unexpected token '<' only in production, this is almost always why. The dev proxy hid the real architecture problem; production exposes it.

Production fix 1: make the API send CORS headers

If you own the API, configure it to allow your production origin, plus http://localhost:5173 if you also want to call it directly in development:

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

Then set VITE_API_BASE=https://api.example.com at build time and call it directly. Remember that JSON bodies and Authorization headers trigger a preflight, which the API must also answer. See CORS preflight requests explained.

Production fix 2: put the API behind your app’s origin

Make production look like development: serve the API under /api on the same origin as the app, so no CORS is involved. Most hosts support this with a rewrite or reverse-proxy rule. With nginx:

location /api/ {
    proxy_pass https://api.example.com/;
    proxy_set_header Host api.example.com;
    proxy_ssl_server_name on;
}

Netlify, Vercel and Cloudflare offer equivalent redirect or rewrite rules. This keeps the code identical in both environments and avoids preflights entirely.

Production fix 3: a small backend

If the API needs a secret key, the call must happen on a server. Add a serverless function or a small backend that holds the key, calls the API, and returns only what the browser needs. Your React app calls that function on its own origin. This is the only option that keeps a private key private.

Production fix 4: a CORS proxy

When the API is a third-party service you can’t configure, its responses are safe to show in the browser, and a backend would be pure overhead, a CORS proxy is the lightest fix.

With ProxifyEdge, keep the same pattern of a configurable base, pointed at the proxy:

const proxy = 'https://api.proxifyedge.com/proxy';

export async function getRepo(name: string) {
  const target = `https://api.github.com/repos/${name}`;
  const response = await fetch(`${proxy}?url=${encodeURIComponent(target)}`, {
    headers: { 'X-API-Key': import.meta.env.VITE_PROXIFY_KEY },
  });
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);
  return response.json();
}

The key is a public key by design. Live keys only answer the origins you list, such as https://app.example.com. Use a test key on localhost, since test keys answer any origin without credentials. If the upstream API needs a token, keep it out of your bundle: store it in the secrets vault and reference it by name. The quick start covers setup, and you can try requests in the playground.

Which fix should you use?

SituationFix
Local development, any APIserver.proxy
You own the APIConfigure CORS on the API
You control the hostingSame-origin rewrite to the API
The API needs a secret keyYour own backend or serverless function
Third-party API, no secret, no backendA CORS proxy with origin-locked keys

Summary

  • CORS errors in a Vite React app come from the API’s missing headers, not from React.
  • server.proxy fixes development by making requests same-origin, and also covers vite preview.
  • It doesn’t exist after vite build; /api then returns 404 or your index.html.
  • In production, configure the API, rewrite /api on your host, add a backend for secrets, or use a CORS proxy.
  • VITE_ variables are public. Never put secrets in them.

Using Vue instead? See fixing CORS errors in Vue and Nuxt. On Next.js, see how to fix CORS errors in Next.js.

Are you sure?