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() and switchMap().
  • 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 HttpClient without a real server.
Pointfetch()HttpClient
ReturnsA PromiseAn Observable
JSON conversionYou call response.json()Automatic
Cancel a requestExtra codeunsubscribe() or switchMap()
Change every request in one placeNot built inInterceptors

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.

MethodWhat it doesHttpClient callExample
GETReads datahttp.get(url)Get all users
POSTCreates new datahttp.post(url, body)Add a user
PUTReplaces existing datahttp.put(url, body)Save an edited user
DELETERemoves datahttp.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 example Bearer abc123.
  • Content-Type: the format of the body. HttpClient sets application/json for you.
  • Accept-Language: the language the user wants, for example en.

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.name exists, and marks typing mistakes such as user.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'>. Omit creates 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.

  1. Before the request, set loading to true.
  2. When the request ends, set it to false. Use finalize() 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 example 404 or 500. It is 0 when the server cannot be reached at all.
  • statusText: a short text, for example Not Found.
  • message: a full error message.

Common status codes:

CodeMeaningMessage for the user
400The request data is wrong"Please check the form."
401The user is not logged in"Please log in again."
404The data does not exist"This user was not found."
500The server has a problem"Something went wrong. Please try again later."
0No 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 Authorization header 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 with req.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:

PartJob
User interfaceDescribes one user: id, name and email
UserApiServiceAll four HTTP requests
App componentThe 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 fileWhat it contains
src/app/user.model.tsThe User interface
src/app/user-api.service.tsUserApiService with GET, POST, PUT and DELETE
src/app/auth.interceptor.tsAn interceptor that adds an Authorization header
src/app/app.ts, app.htmlThe User Management component
src/environments/apiUrl for development and for the live app
server/server.jsThe 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.

  1. Unzip the file, open a terminal and go to the project folder:

    cd angular-user-management
  2. Install the packages:

    npm install
  3. Start the API server. It runs at http://localhost:3000. Keep this terminal open:

    npm run api
  4. Open a second terminal in the same folder and start the Angular app:

    npm start
  5. Open http://localhost:4200 in 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

MistakeWhat you seeFix
No provideHttpClient() in app.config.tsError: No provider for HttpClientAdd provideHttpClient() to the providers
Calling api.addUser(...) without subscribe()No request is sentCall .subscribe(), or use the async pipe
Using the result outside subscribe()The value is still empty, because the answer has not arrivedUse the value inside subscribe() or with the async pipe
No error handlingA failed request leaves the page empty, with no messageAdd catchError() or an error callback
Writing the server URL in every serviceMany files to change for the live appStore apiUrl in the environment files
Changing req in an interceptorError, because requests cannot be changedUse req.clone(...)

Check Yourself

Exercises

What to Remember

  • HttpClient sends requests to a server. Turn it on with provideHttpClient(), keep all requests in an API service, and store the server URL in the environment files.
  • get() reads, post() creates, put() replaces and delete() removes data. Each returns an Observable, and the request is sent only when something subscribes.
  • Show a loading indicator with finalize(), handle errors with catchError(), and use interceptors for work that every request needs, such as an Authorization header.

Next: Angular Signals, a simple way to store values that update the screen.