Your Angular app runs on http://localhost:4200, the API on http://localhost:3000, and every request fails. Chrome’s console shows:
Access to XMLHttpRequest at 'http://localhost:3000/api/users' from origin 'http://localhost:4200' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.and your HttpClient error handler receives an HttpErrorResponse with status: 0 and the message Http failure response for http://localhost:3000/api/users: 0 Unknown Error. Port 4200 and port 3000 are different origins, the API did not send an Access-Control-Allow-Origin header, so the browser refused to hand the response to your code. With provideHttpClient(withFetch()) the first line says fetch instead of XMLHttpRequest; Firefox words it as “Cross-Origin Request Blocked”. Everything here was reproduced with an Angular 20 CLI project and a plain Node backend.
Quick fix checklist
- Change API calls to relative URLs (
/api/users), nothttp://localhost:3000/api/users. - Create
proxy.conf.jsonmapping/apito the backend’s origin. - Wire it in
angular.jsonunder theservetarget ("proxyConfig": "proxy.conf.json") or runng serve --proxy-config proxy.conf.json. - Restart
ng serve: proxy changes are not picked up by the file watcher. - Test with
curl -i http://localhost:4200/api/usersbefore blaming Angular. - For production, serve the app and API from one origin, or configure CORS on the backend.
Before you start
You need the backend running and reachable from your machine (curl http://localhost:3000/api/users should return data), and an Angular CLI project using the default @angular/build:dev-server builder. Note the API’s path layout: does it already serve everything under /api, or at the root? That decides whether you need a path rewrite.
If CORS itself is fuzzy, read why browsers block cross-origin responses first; this page focuses on the Angular dev-server side.
Why it happens
An origin is scheme + host + port. http://localhost:4200 and http://localhost:3000 differ in port, so the browser treats a request between them as cross-origin. The browser still sends it (our backend logged GET /api/users origin=http://localhost:4200), but before exposing the response to JavaScript it checks for Access-Control-Allow-Origin. Missing header, blocked response, status: 0. Requests with JSON bodies or auth headers are worse: the browser first sends an OPTIONS preflight, and if that fails, the real request is never sent.
There are two ways out. The backend can opt in by sending CORS headers (see Spring Boot CORS configuration for a Java API). Or the browser can stop making cross-origin requests at all. The dev-server proxy does the second: the app calls http://localhost:4200/api/users, which is same-origin, and the dev server forwards it to http://localhost:3000 from Node. CORS is a browser rule, so the server-to-server hop is unaffected.
Step-by-step walkthrough
Step 1: Confirm it is really CORS
Open DevTools, Network tab. A CORS failure shows the request with a “CORS error” status, while the backend log shows that it received the request. If the request never reached the backend, or curl http://localhost:3000/api/users also fails, you have a connectivity problem instead (wrong port, server down, firewall) and a proxy will not help.
Step 2: Use relative URLs in the app
The proxy only sees requests sent to the dev server. An absolute URL to port 3000 bypasses it completely, which is the most common reason “the proxy doesn’t work”.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UserApi {
private http = inject(HttpClient);
list() {
return this.http.get<User[]>('/api/users'); // relative: same origin as the app
}
}Relative URLs also work unchanged in production when the app and API share an origin, which is the setup recommended below.
Step 3: Write the proxy configuration
Create proxy.conf.json next to angular.json:
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true
}
}What each field does:
- Key (
/api): requests whose path starts with this string are proxied. It is a plain prefix match, so/apialso matches/apix/users(verified). Use/api/if that matters. Keys starting with^are treated as regular expressions, and glob keys such as/api/**are converted for you. target: the backend origin. The path is appended unchanged unless you rewrite it.secure: setfalsewhen the target ishttpswith a self-signed certificate; with anhttptarget it changes nothing.changeOrigin: rewrites theHostheader to the target. In our test the backend sawhost=localhost:4200without it andhost=localhost:3000with it. Turn it on for virtual-hosted or cloud backends that route by host name.
If the backend does not use the /api prefix, strip it with pathRewrite:
{
"/api": {
"target": "http://localhost:3000",
"pathRewrite": { "^/api": "" }
}
}With this file, GET /api/users arrived at the backend as GET /users. The JSON file may contain comments and trailing commas; for logic (environment variables, several targets), use proxy.conf.mjs or proxy.conf.js and export the same object.
The current esbuild/Vite-based dev server ignores the logLevel option that older webpack-based guides set, so do not expect per-request logs. It does print failures: with the backend stopped, the browser got a 500 and the terminal showed [vite] http proxy error: /users followed by AggregateError [ECONNREFUSED].
Step 4: Wire it into angular.json
Command-line flag, handy for a quick test:
ng serve --proxy-config proxy.conf.jsonPermanent setup, so npm start uses it for everyone:
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": { "buildTarget": "my-app:build:production" },
"development": { "buildTarget": "my-app:build:development" }
},
"defaultConfiguration": "development"
}The path is relative to the workspace root. Restart ng serve after any change to the proxy file: in our test, editing the target while the server ran had no effect until a restart. Older projects using @angular-devkit/build-angular:dev-server take the same proxyConfig option.
Worked scenario
A Spring Boot API runs on port 8080 with endpoints under /api. The Angular service was written against a hard-coded origin:
// order-api.ts (broken)
list() {
return this.http.get<Order[]>('http://localhost:8080/api/orders');
}Diagnosis: the console shows the “blocked by CORS policy” message for http://localhost:8080/api/orders, the network tab shows a CORS error, and the Spring log shows the request was handled. Nothing is wrong with the endpoint; the browser is hiding the response.
Development fix: relative URL plus proxy.
list() {
return this.http.get<Order[]>('/api/orders');
}{
"/api/": {
"target": "http://localhost:8080",
"changeOrigin": true
}
}Production fix: ng build produces static files in dist/my-app/browser/; there is no dev server and no proxy. Serve those files and the API from one origin with a reverse proxy, so the same relative URLs keep working:
server {
listen 80;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://orders-api:8080;
}
location / {
try_files $uri $uri/ /index.html;
}
}If the frontend must live on a different origin (say app.example.com calling api.example.com), configure CORS on the API for exactly that origin instead.
Common mistake
Adding Access-Control-Allow-Origin to the Angular request. It is a response header that only the server can send. Setting it on the request adds a non-standard header, which makes the browser send a preflight and usually fails sooner.
Expecting the proxy to work after ng build. The proxy belongs to ng serve. A deployed app that still calls /api with no reverse proxy in front gets 404s from the static host, or index.html returned as the “JSON” response.
Keeping an absolute http://localhost:... URL in an environment file. The proxy never sees it, and it leaks a development address into builds. Use a relative base URL, or an empty apiBaseUrl that you prefix onto paths.
Disabling browser security. Launching Chrome with --disable-web-security or installing a “CORS unblock” extension hides the problem on one machine and teaches you nothing about production.
Verify the behavior
With the backend and ng serve running, hit the API through the dev server:
curl -i http://localhost:4200/api/usersExpected output (abridged, from our test setup):
HTTP/1.1 200 OK
Vary: Origin
content-type: application/json
[{"id":1,"name":"Ada"}]If the key does not match, the request never reaches the proxy: the dev server answers with index.html and status 200, and HttpClient reports Http failure during parsing for http://localhost:4200/... because HTML is not JSON. A 404 from the backend means the key matched but the path rewrite is wrong (check the backend log for the path it received). A 500 with http proxy error in the terminal means the target is unreachable. Then reload the app: the Network tab should show http://localhost:4200/api/users with status 200, and the console should contain no CORS message. Same-origin GET requests from the browser do not even carry an Origin header, which you can confirm in the backend log.
Interview exercise
“Your Angular app works with ng serve but every API call fails after deployment to a static hosting bucket. Locally you use proxy.conf.json. Explain what happened and how you would design the production setup.”
Answer and reasoning
The proxy was hiding the cross-origin boundary. Under ng serve, the browser only talked to localhost:4200, and the dev server forwarded /api calls server-side, where CORS does not exist. ng build emits static files; the proxy configuration is not part of the output, so in production /api/... goes to the static host, which has no API.
There are two sound designs. Same-origin: put a reverse proxy or CDN rule in front (nginx, a load balancer, CloudFront behaviors) that serves the app and routes /api/* to the backend; the Angular code keeps its relative URLs and no CORS configuration is needed. Cross-origin: deploy the API on its own domain, set the API base URL per environment, and configure the backend to allow the app’s exact origin, the methods and headers you use, and credentials if you send cookies. A strong answer mentions that wildcard origins cannot be combined with credentials, and that the choice also affects cookies and CSRF protection.