API Integration in Angular
By Syncfusion®, Inc. · Last updated
Talking to a server with HttpClient: setup, environment files, the API service pattern, GET, POST, PUT and DELETE, headers, typed responses, loading indicators, error handling, interceptors, and a User Management capstone project.
What Is HttpClient?
Most apps get their data from a server: a computer on the internet that stores the data. The app and the server talk through an API (Application Programming Interface): a list of URLs that the server answers, such as /api/users.
HttpClient is the Angular service that sends HTTP requests to a server and receives its responses. It is in the package @angular/common/http.
Real-World Example
An HR app shows a list of employees. When the page opens, a UserApiService uses HttpClient to request GET /api/users, and the server answers with the list in JSON format.
When an HR manager adds a new employee, the service sends POST /api/users with the new employee's data, and the list updates.
Componentcalls api.getUsers()UserApiServicehttp.get(url)HttpClientsends requestServer APIGET /api/usersrequest →← response: [ { id: 1, name: 'Asha Rao' }, ... ]
A request goes to the server, and the response comes back as an Observable.
Why HttpClient?
The browser already has a function to send requests, fetch(). HttpClient adds features that Angular apps need:
- JSON automatically: it converts the server's JSON answer into JavaScript objects for you.
- Observables: each request returns an Observable, so you can use the RxJS operators that you know, such as
map(),catchError()andswitchMap(). - Interceptors: one function can change every request, for example to add a login token.
- Typed responses: you tell TypeScript what the answer contains, so the editor can check your code.
- Easy testing: Angular has tools to test code that uses
HttpClientwithout a real server.
| Point | fetch() | HttpClient |
|---|---|---|
| Returns | A Promise | An Observable |
| JSON conversion | You call response.json() | Automatic |
| Cancel a request | Extra code | unsubscribe() or switchMap() |
| Change every request in one place | Not built in | Interceptors |
Setting Up HttpClient
HttpClient is a service, but Angular does not provide it until you turn it on. Add provideHttpClient() to the providers in app.config.ts:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient()],
};
Then inject it in any component or service:
private http = inject(HttpClient);
The practice blocks cannot reach a real server. Each block declares its own appConfig in app.ts, and adds a small practice server function that answers the requests inside your browser. It is at the end of each block, and you do not need to read it now. The section "HTTP Interceptors" explains how it works.
The next example requests the list of users. http.get() returns an Observable, and the async pipe from the RxJS lesson subscribes to it and displays the result.
Your first GET request
import { ApplicationConfig, Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<h4>Employees</h4>
@for (user of users$ | async; track user.id) {
<p>{{ user.name }} ({{ user.email }})</p>
} @empty {
<p>Loading...</p>
}
`,
})
export class App {
private http = inject(HttpClient);
users$ = this.http.get<any[]>('/api/users');
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
The request is sent when the async pipe subscribes. After 300 milliseconds, the practice server answers, and the list replaces "Loading...".
Environment Configuration
The server URL is different in each place the app runs. On your computer it can be http://localhost:3000, and in the live app https://api.myshop.com. Do not write the URL in every service. Store it in an environment file.
Create the files with the CLI:
ng generate environments
This creates two files in src/environments:
// environment.ts — used by "ng build" for the live app
export const environment = {
apiUrl: 'https://api.myshop.com',
};
// environment.development.ts — used by "ng serve" on your computer
export const environment = {
apiUrl: 'http://localhost:3000',
};
Import environment from environment.ts in your code. When you run ng serve, the CLI uses the development file instead, automatically.
import { environment } from '../environments/environment';
const url = `${environment.apiUrl}/api/users`;
Environment files are part of the app that every user downloads. Never put passwords or secret keys in them.
API Service Pattern
Do not call HttpClient directly in components. Put all requests for one kind of data in one API service, for example UserApiService for users.
- One place for URLs: when a URL changes, you edit one file.
- Short components: components call
api.getUsers()and only display the result. - Reuse: many components use the same methods.
A UserApiService
import { ApplicationConfig, Component, Injectable, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
const environment = { apiUrl: '/api' }; // from environment.ts in a real project
@Injectable({ providedIn: 'root' })
export class UserApiService {
private http = inject(HttpClient);
private baseUrl = `${environment.apiUrl}/users`;
getUsers() {
return this.http.get<any[]>(this.baseUrl);
}
getUser(id: number) {
return this.http.get<any>(`${this.baseUrl}/${id}`);
}
}
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<p>All users: {{ (users$ | async)?.length }}</p>
<p>User 2: {{ (user2$ | async)?.name }}</p>
`,
})
export class App {
private api = inject(UserApiService);
users$ = this.api.getUsers();
user2$ = this.api.getUser(2);
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
The component does not know any URL. It only calls methods of UserApiService.
HTTP Methods
Every HTTP request has a method. The method tells the server what to do with the data at the URL.
GET/api/usersread dataPOST/api/users + bodycreate dataPUT/api/users/1 + bodyreplace dataDELETE/api/users/1remove data
The method tells the server what to do with the data at the URL.
| Method | What it does | HttpClient call | Example |
|---|---|---|---|
| GET | Reads data | http.get(url) | Get all users |
| POST | Creates new data | http.post(url, body) | Add a user |
| PUT | Replaces existing data | http.put(url, body) | Save an edited user |
| DELETE | Removes data | http.delete(url) | Delete a user |
The body is the data you send to the server, for example the new user. GET and DELETE requests have no body.
GET Requests
get() reads data. You saw it above:
getUsers() {
return this.http.get<User[]>('/api/users');
}
POST Requests
post() sends new data to the server. The server saves it and usually answers with the saved object, including its new id:
addUser(user: NewUser) {
return this.http.post<User>('/api/users', user);
}
PUT Requests
put() sends the full, changed object for an existing id. The server replaces the old data:
updateUser(user: User) {
return this.http.put<User>(`/api/users/${user.id}`, user);
}
DELETE Requests
delete() removes the data with this id:
deleteUser(id: number) {
return this.http.delete<void>(`/api/users/${id}`);
}
An HttpClient method only prepares the request. The request is sent when something subscribes: the async pipe or subscribe(). If you call api.addUser(user) without .subscribe(), nothing is sent.
The next example uses all four methods. The list is stored in a BehaviorSubject from the RxJS lesson, and load() requests it again after every change.
GET, POST, PUT and DELETE
import { ApplicationConfig, Component, Injectable, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { BehaviorSubject, delay, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Injectable({ providedIn: 'root' })
export class UserApiService {
private http = inject(HttpClient);
getUsers() {
return this.http.get<any[]>('/api/users');
}
addUser(user: any) {
return this.http.post<any>('/api/users', user);
}
updateUser(user: any) {
return this.http.put<any>(`/api/users/${user.id}`, user);
}
deleteUser(id: number) {
return this.http.delete<void>(`/api/users/${id}`);
}
}
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<button (click)="add()">POST: add Meena</button>
<button (click)="rename()">PUT: rename user 1</button>
<button (click)="remove()">DELETE: user 2</button>
@for (user of users$ | async; track user.id) {
<p>#{{ user.id }} {{ user.name }}</p>
}
`,
})
export class App {
private api = inject(UserApiService);
users$ = new BehaviorSubject<any[]>([]);
constructor() {
this.load();
}
load() {
this.api.getUsers().subscribe(list => this.users$.next(list));
}
add() {
this.api.addUser({ name: 'Meena Iyer', email: 'meena@shop.com' }).subscribe(() => this.load());
}
rename() {
this.api.updateUser({ id: 1, name: 'Asha R.', email: 'asha@shop.com' }).subscribe(() => this.load());
}
remove() {
this.api.deleteUser(2).subscribe(() => this.load());
}
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
Click the three buttons. Each one sends a request, and when the server answers, load() gets the new list.
Request Headers
Headers are extra information sent with a request, as name and value pairs. Common headers:
Authorization: proves who the user is, for exampleBearer abc123.Content-Type: the format of the body.HttpClientsetsapplication/jsonfor you.Accept-Language: the language the user wants, for exampleen.
Pass headers in the options object, the last argument of each method:
Sending headers with a request
import { ApplicationConfig, Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpHeaders, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<p>The server received these headers:</p>
@for (line of received$ | async; track $index) {
<p>{{ line }}</p>
}
`,
})
export class App {
private http = inject(HttpClient);
private headers = new HttpHeaders({
'Accept-Language': 'en',
'X-App-Version': '2.1',
});
// This practice URL answers with the headers it received.
received$ = this.http.get<string[]>('/api/echo-headers', { headers: this.headers });
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
To add the same header to every request, such as Authorization, use an interceptor. You learn it below.
Typed API Responses
http.get() does not know what the server sends. You tell TypeScript with an interface and a type in < >:
export interface User {
id: number;
name: string;
email: string;
}
getUsers() {
return this.http.get<User[]>('/api/users'); // an Observable of a list of User
}
- The editor now knows that
user.nameexists, and marks typing mistakes such asuser.nmae. - The type is only a promise to TypeScript. It does not check the server's real answer. If the server sends different fields, your code still receives them.
- For a new user without an
id, use a second type:type NewUser = Omit<User, 'id'>.Omitcreates a copy of a type without the fields that you list.
Loading Indicators
A request takes time. During this time, show a loading indicator, for example the text "Loading..." or a spinner, so the user knows the app is working.
loading = trueshow "Loading..."Request sentGET /api/usersAnswer or errorafter some timefinalize()loading = falsefinalize() runs after success and after an error, so the indicator never stays.
A loading indicator covers the time between the request and the answer.
- Before the request, set
loadingtotrue. - When the request ends, set it to
false. Usefinalize()from the RxJS lesson, because it runs after success and after an error.
A loading indicator
import { ApplicationConfig, Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { BehaviorSubject, delay, finalize, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<button (click)="load()" [disabled]="loading$ | async">Reload users</button>
@if (loading$ | async) {
<p>⏳ Loading...</p>
} @else {
<p>{{ count$ | async }} users loaded.</p>
}
`,
})
export class App {
private http = inject(HttpClient);
loading$ = new BehaviorSubject(false);
count$ = new BehaviorSubject(0);
constructor() {
this.load();
}
load() {
this.loading$.next(true);
this.http
.get<any[]>('/api/users')
.pipe(finalize(() => this.loading$.next(false)))
.subscribe(list => this.count$.next(list.length));
}
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
Click Reload users. The button is disabled and "Loading..." is shown until the server answers.
HTTP Error Handling
A request can fail. When it fails, HttpClient sends an error through the Observable, with an HttpErrorResponse object. Its most useful properties:
RequestGET /api/users/99Server404 Not FoundcatchError()show a clear messageof(null)page keeps workingWithout catchError, the error stops the Observable and the page stays empty.
catchError() turns a failed request into a message and a safe value.
status: the HTTP status code, for example404or500. It is0when the server cannot be reached at all.statusText: a short text, for exampleNot Found.message: a full error message.
Common status codes:
| Code | Meaning | Message for the user |
|---|---|---|
| 400 | The request data is wrong | "Please check the form." |
| 401 | The user is not logged in | "Please log in again." |
| 404 | The data does not exist | "This user was not found." |
| 500 | The server has a problem | "Something went wrong. Please try again later." |
| 0 | No connection to the server | "Check your internet connection." |
Use catchError() to show a clear message and return a replacement value:
Handling a failed request
import { ApplicationConfig, Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { BehaviorSubject, catchError, delay, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<p>User 99: {{ (user$ | async)?.name ?? '-' }}</p>
<p>Reports: {{ (reports$ | async)?.length ?? '-' }}</p>
@for (message of errors$ | async; track $index) {
<p>⚠ {{ message }}</p>
}
`,
})
export class App {
private http = inject(HttpClient);
errors$ = new BehaviorSubject<string[]>([]);
user$ = this.http.get<any>('/api/users/99').pipe(
catchError((error: HttpErrorResponse) => {
this.showError(error);
return of(null);
}),
);
reports$ = this.http.get<any[]>('/api/broken').pipe(
catchError((error: HttpErrorResponse) => {
this.showError(error);
return of([]);
}),
);
showError(error: HttpErrorResponse) {
const text = error.status === 404 ? 'This user was not found.' : 'Something went wrong. Please try again later.';
this.errors$.next([...this.errors$.value, `${error.status} ${error.statusText}: ${text}`]);
}
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
User 99 does not exist, so the server answers 404. The second URL fails with 500. Both errors are caught, and the page shows a message for each.
HTTP Interceptors
An interceptor is a function that runs for every request that HttpClient sends. It can change the request before it leaves, and read or change the response.
HttpClienthttp.get(url)authInterceptoradds AuthorizationloggingInterceptorwrites a log lineServeranswers← the response comes back through the same interceptors
Every request passes through each interceptor in order.
Typical uses:
- Add an
Authorizationheader with the login token to every request. - Write a log line for every request.
- Show one error message for all failed requests.
An interceptor gets two arguments:
req: the request. Requests cannot be changed, so you create a changed copy withreq.clone(...).next: a function that passes the request on.next(req)sends it to the next interceptor, or to the server.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const copy = req.clone({ setHeaders: { Authorization: 'Bearer abc123' } });
return next(copy);
};
Register interceptors with withInterceptors(). They run in the order of the list:
providers: [provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor]))],
The practice server in these blocks is an interceptor too. It is the last one in the list, and it returns a response itself instead of calling next(req). So the request never leaves the browser.
Two interceptors
import { ApplicationConfig, Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpInterceptorFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, tap, throwError, timer } from 'rxjs';
export const requestLog: string[] = [];
// 1. Adds the login token to every request
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const copy = req.clone({ setHeaders: { Authorization: 'Bearer abc123' } });
return next(copy);
};
// 2. Writes one log line for every response
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
requestLog.push(`${req.method} ${req.url} → ${event.status}`);
}
}),
);
};
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor, loggingInterceptor, practiceServer])),
],
};
@Component({
selector: 'app-root',
imports: [AsyncPipe],
template: `
<p>Headers that reached the server:</p>
@for (line of received$ | async; track $index) {
<p>{{ line }}</p>
}
<p>Log: {{ log.join(', ') }}</p>
`,
})
export class App {
private http = inject(HttpClient);
received$ = this.http.get<string[]>('/api/echo-headers');
log = requestLog;
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
The component did not add the Authorization header, but the server received it, because authInterceptor added it.
Capstone Project
User Management System
Now you combine everything from this lesson in one small app: a User Management System for an HR team. It can:
- Get users: show the list when the page opens.
- Add user: fill in the form and click Add.
- Edit user: click Edit, change the form and click Save.
- Delete user: click Delete next to a user.
The app has three parts:
| Part | Job |
|---|---|
User interface | Describes one user: id, name and email |
UserApiService | All four HTTP requests |
App component | The form, the list, the loading text and the error message |
The form uses ngModel from the Template-Driven Forms lesson.
Get Users
load() calls getUsers() when the page opens and after every change, and puts the list into users$.
Add User
When no user is being edited, Add calls addUser() with the form values. The server answers with the new user, including its new id.
Edit User
Edit copies the user into the form and stores its id in editingId. Then the button shows Save, and it calls updateUser().
Delete User
Delete calls deleteUser() with the user's id, then reloads the list.
Capstone: User Management System
import { ApplicationConfig, Component, Injectable, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { BehaviorSubject, delay, finalize, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
const environment = { apiUrl: '/api' };
// ── Model ──
export interface User {
id: number;
name: string;
email: string;
}
// ── API service ──
@Injectable({ providedIn: 'root' })
export class UserApiService {
private http = inject(HttpClient);
private url = `${environment.apiUrl}/users`;
getUsers() {
return this.http.get<User[]>(this.url);
}
addUser(name: string, email: string) {
return this.http.post<User>(this.url, { name, email });
}
updateUser(user: User) {
return this.http.put<User>(`${this.url}/${user.id}`, user);
}
deleteUser(id: number) {
return this.http.delete<void>(`${this.url}/${id}`);
}
}
// ── Component ──
@Component({
selector: 'app-root',
imports: [AsyncPipe, FormsModule],
template: `
<h4>User Management</h4>
<form (ngSubmit)="save()">
<input name="name" [(ngModel)]="name" placeholder="Name" required />
<input name="email" [(ngModel)]="email" placeholder="Email" required />
<button type="submit">{{ editingId ? 'Save' : 'Add' }}</button>
@if (editingId) {
<button type="button" (click)="cancel()">Cancel</button>
}
</form>
@if (loading$ | async) {
<p>⏳ Loading...</p>
}
@if (error$ | async; as error) {
<p>⚠ {{ error }}</p>
}
@for (user of users$ | async; track user.id) {
<p>
{{ user.name }} ({{ user.email }})
<button (click)="edit(user)">Edit</button>
<button (click)="remove(user.id)">Delete</button>
</p>
} @empty {
<p>No users.</p>
}
`,
})
export class App {
private api = inject(UserApiService);
users$ = new BehaviorSubject<User[]>([]);
loading$ = new BehaviorSubject(false);
error$ = new BehaviorSubject('');
name = '';
email = '';
editingId: number | null = null;
constructor() {
this.load();
}
load() {
this.loading$.next(true);
this.api
.getUsers()
.pipe(finalize(() => this.loading$.next(false)))
.subscribe({
next: list => this.users$.next(list),
error: () => this.error$.next('Could not load users.'),
});
}
save() {
if (!this.name || !this.email) {
this.error$.next('Name and email are required.');
return;
}
this.error$.next('');
const request = this.editingId
? this.api.updateUser({ id: this.editingId, name: this.name, email: this.email })
: this.api.addUser(this.name, this.email);
request.subscribe({
next: () => {
this.cancel();
this.load();
},
error: () => this.error$.next('Could not save the user.'),
});
}
edit(user: User) {
this.editingId = user.id;
this.name = user.name;
this.email = user.email;
}
cancel() {
this.editingId = null;
this.name = '';
this.email = '';
}
remove(id: number) {
this.api.deleteUser(id).subscribe({
next: () => this.load(),
error: () => this.error$.next('Could not delete the user.'),
});
}
}
// ─── Practice server ──────────────────────────────────────────────
// The practice blocks cannot reach a real server. This function answers
// the requests inside the browser. You learn how it works in the
// section "HTTP Interceptors". You do not need to read it now.
let users = [
{ id: 1, name: 'Asha Rao', email: 'asha@shop.com' },
{ id: 2, name: 'Ravi Kumar', email: 'ravi@shop.com' },
];
let nextId = 3;
function practiceServer(req: HttpRequest<any>, next: HttpHandlerFn) {
const id = Number(req.url.split('/')[3]);
if (req.url === '/api/broken') return fail(500, 'Internal Server Error');
if (req.url === '/api/echo-headers') {
return reply(req.headers.keys().map(key => `${key}: ${req.headers.get(key)}`));
}
if (req.url === '/api/users' && req.method === 'GET') return reply(users);
if (req.url === '/api/users' && req.method === 'POST') {
const user = { ...req.body, id: nextId++ };
users = [...users, user];
return reply(user, 201);
}
if (req.method === 'GET') {
const user = users.find(u => u.id === id);
return user ? reply(user) : fail(404, 'Not Found');
}
if (req.method === 'PUT') {
users = users.map(u => (u.id === id ? { ...req.body, id } : u));
return reply({ ...req.body, id });
}
if (req.method === 'DELETE') {
users = users.filter(u => u.id !== id);
return reply(null, 204);
}
return fail(404, 'Not Found');
}
function reply(body: unknown, status = 200) {
return of(new HttpResponse({ status, body })).pipe(delay(300));
}
function fail(status: number, statusText: string) {
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status, statusText }))));
}
Try all four actions: add a user, edit one, delete one, and click Add with an empty form to see the error message.
Download Project Files
Download API Project Source Code
Download angular-user-management.zip
The ZIP file contains the capstone as a complete Angular CLI project, with a small real server written in Node.js. In this project, the requests leave the browser and go to the server, so there is no practice server.
| Folder or file | What it contains |
|---|---|
src/app/user.model.ts | The User interface |
src/app/user-api.service.ts | UserApiService with GET, POST, PUT and DELETE |
src/app/auth.interceptor.ts | An interceptor that adds an Authorization header |
src/app/app.ts, app.html | The User Management component |
src/environments/ | apiUrl for development and for the live app |
server/server.js | The API server. It stores the users in memory |
Project Setup Instructions
You need Node.js 20.19 or newer. Check your version with node -v.
Unzip the file, open a terminal and go to the project folder:
cd angular-user-managementInstall the packages:
npm installStart the API server. It runs at
http://localhost:3000. Keep this terminal open:npm run apiOpen a second terminal in the same folder and start the Angular app:
npm start- Open
http://localhost:4200in your browser.
The server keeps the users in memory, so the list returns to the first two users each time you restart npm run api.
Common Mistakes
| Mistake | What you see | Fix |
|---|---|---|
No provideHttpClient() in app.config.ts | Error: No provider for HttpClient | Add provideHttpClient() to the providers |
Calling api.addUser(...) without subscribe() | No request is sent | Call .subscribe(), or use the async pipe |
Using the result outside subscribe() | The value is still empty, because the answer has not arrived | Use the value inside subscribe() or with the async pipe |
| No error handling | A failed request leaves the page empty, with no message | Add catchError() or an error callback |
| Writing the server URL in every service | Many files to change for the live app | Store apiUrl in the environment files |
Changing req in an interceptor | Error, because requests cannot be changed | Use req.clone(...) |
Check Yourself
Exercises
What to Remember
HttpClientsends requests to a server. Turn it on withprovideHttpClient(), keep all requests in an API service, and store the server URL in the environment files.get()reads,post()creates,put()replaces anddelete()removes data. Each returns an Observable, and the request is sent only when something subscribes.- Show a loading indicator with
finalize(), handle errors withcatchError(), and use interceptors for work that every request needs, such as anAuthorizationheader.
Next: Angular Signals, a simple way to store values that update the screen.