CORS: When Client and Server Live Apart
During development, Sprout's React app runs at http://localhost:5173 and its API at http://localhost:3000. The very first fetch fails with a red "blocked by CORS policy" message, even though the API answers perfectly when you call it from the terminal. Nothing is broken. The browser is enforcing a rule about origins, and once you know the rule the fix takes a minute.
The rule
Different origin: the browser asks, the server's headers answer.
An origin is three things together: scheme, host and port. http://localhost:5173 and http://localhost:3000 differ only in the port, and that is enough to make them different origins.
Under the browser's same-origin policy, a page's JavaScript may send a request to another origin, but it is not allowed to read the response unless that server says the page's origin is welcome. It says so with response headers:
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: trueFor anything beyond a plain request, such as a JSON body or a PATCH or DELETE, the browser first sends a preflight: an OPTIONS request asking which methods and headers are allowed. Only if the answer permits it does the real request go out.
Cookies add two rules. The client must opt in with fetch(url, { credentials: 'include' }), and the server must name the exact origin and send Access-Control-Allow-Credentials: true. The wildcard Access-Control-Allow-Origin: * is refused when credentials are involved.
The most misunderstood point: CORS protects users' browsers, not your server. curl, scripts and tools such as Postman ignore it completely. It is never a replacement for login and ownership checks.
There are two good fixes.
First fix: make it one origin. In development, let Vite's dev server forward /api to the API, so the browser only ever talks to port 5173:
// client/vite.config.js
export default {
server: {
proxy: { '/api': 'http://localhost:3000' },
},
};In production, the web server does the same job: pages and /api share https://sprout.example.com, so CORS never comes up.
Second fix: allow a known origin. When the client really lives elsewhere, such as a page on app.sprout.example.com calling an API on api.sprout.example.com, or a partner's website, configure the API to allow an explicit list of exact origins.
The shortcut
When you see a CORS error, compare the page's origin with the request's origin: scheme, host, port. All three match? It is not CORS; look elsewhere. Any differ? Either proxy to make them match, or allow the page's origin on the server.
The two origins
The page is
http://localhost:5173. The code callsfetch('http://localhost:3000/api/plants').
Drills
Answer fast — trust the rule.
Which pair of URLs shares the same origin?
Who enforces CORS?
Does a strict CORS setup stop someone from calling Sprout's API with curl?
Sprout sends cookies on cross-origin requests. Which server header does the browser refuse in that case?
Remember
- Different origin: the browser asks, the server's headers answer.
- Shortcut: compare scheme, host and port. Match means not CORS; mismatch means proxy or allow the origin.
- Trap: treating CORS as security. It guards browsers only, and
*does not work with cookies.
