Mert Özen Further With Every Line
Frontend Angular — #12

Angular HttpClient for API Requests: GET, POST, Error Handling and Interceptors

Mert Özen Oct 31, 2026 9 min 2 views
Angular HttpClient for API Requests: GET, POST, Error Handling and Interceptors

We send GET, POST, PUT and DELETE requests with Angular 12's HttpClient. Typed responses, HttpParams, error handling with catchError and retry, class-based interceptors and environment config.

Version note: The code and commands in this post are for Angular 12.

The Data Lives Somewhere Else

In the components we have written so far, the data always lived inside the class: a hand-typed product list, a hard-coded user name. Real applications don't work like that. Products sit in a database, users live on a server, and the Angular app reaches them over HTTP.

Think of it as a courier. You are at home, the warehouse is across town. The courier picks up the package and brings it over; if the address is wrong or the warehouse is closed, they come back empty-handed and let you know. In Angular, that courier is called HttpClient. In this post we will set it up, have it run the four basic errands, catch errors, and write the shared gate every request passes through: the interceptor. So we can experiment without writing a backend, the examples use JSONPlaceholder, a free fake API.

Introducing HttpClientModule to the App

HttpClient ships in the @angular/common/http package, but it isn't ready out of the box. First you add its module to AppModule:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    HttpClientModule
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Without this import, injecting HttpClient into a service gives you a NullInjectorError in the browser console ending with No provider for HttpClient!. The message tells you exactly what is wrong: the injector couldn't find a provider that knows how to create HttpClient. Importing HttpClientModule adds those providers to the app. Doing it once, in the root module, is enough.

The First GET Request and a Typed Response

We don't put HTTP calls inside components; that is a service's job. Let's create one of the providedIn: 'root' services we saw earlier as src/app/user.service.ts and start by describing the shape of the incoming data:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private apiUrl = 'https://jsonplaceholder.typicode.com/users';

  constructor(private http: HttpClient) { }

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.apiUrl);
  }
}

The User interface tells TypeScript which fields each user from the server has. JSONPlaceholder actually returns many more fields; we only listed the ones we use. HttpClient is injected through the constructor, just like any other service.

The real work happens on the get<User[]> line. The generic type in angle brackets says "the response to this request is an array of users." Leave it out and the return type becomes Object, and the compiler complains the moment you write user.name in the component. Keep one thing in mind, though: this type is a promise, not a check. If the server sends something shaped differently, TypeScript won't catch it at runtime.

The method returns an Observable. As we saw earlier, observables are lazy; not a single request hits the network until someone subscribes. Now let's generate a component with ng g c user-list and use the service:

import { Component, OnInit } from '@angular/core';
import { User, UserService } from '../user.service';

@Component({
  selector: 'app-user-list',
  templateUrl: './user-list.component.html'
})
export class UserListComponent implements OnInit {
  users: User[] = [];

  constructor(private userService: UserService) { }

  ngOnInit(): void {
    this.userService.getUsers().subscribe(users => this.users = users);
  }
}
<ul>
  <li *ngFor="let user of users">{{ user.name }} - {{ user.email }}</li>
</ul>

We start the request in ngOnInit, write the incoming array to the users property, and the template lists it. Open the browser's Network tab and you will see a single GET request to the users endpoint.

You may have noticed we didn't unsubscribe. The observable returned by HttpClient completes on its own as soon as the response arrives; it isn't a stream that keeps flowing. Still, if you want to cancel a request that is in flight when the component is destroyed, the async pipe or the takeUntil pattern we saw earlier works here too.

POST, PUT and DELETE

Reading is only half the job. Let's add three more methods to the same service for creating, updating and deleting:

createUser(user: Omit<User, 'id'>): Observable<User> {
  return this.http.post<User>(this.apiUrl, user);
}

updateUser(user: User): Observable<User> {
  return this.http.put<User>(`${this.apiUrl}/${user.id}`, user);
}

deleteUser(id: number): Observable<void> {
  return this.http.delete<void>(`${this.apiUrl}/${id}`);
}

post takes the request body as its second argument. You pass a plain JavaScript object, and HttpClient serializes it to JSON and adds the Content-Type: application/json header for you. Omit<User, 'id'> means "a User without the id"; the server assigns the id of a new record. put knows which record to update from the id in the URL, and delete carries no body, which is why we typed its result as void.

Using them in a component looks just like GET:

addUser(): void {
  this.userService.createUser({ name: 'Ayşe Demir', email: 'ayse@example.com' })
    .subscribe(created => this.users.push(created));
}

JSONPlaceholder pretends to save the record and returns the new user with id: 11, but nothing is actually stored. That is all we need for practice.

Here is the trap beginners fall into most often: these methods don't run without a subscription either. Write this.userService.deleteUser(3); and walk away, and nothing gets deleted, because the request never leaves. It's like giving the courier the address and then not letting them out the door.

HttpParams and HttpHeaders

Instead of gluing query strings and headers onto the URL by hand, we use two helper classes from Angular:

import { HttpHeaders, HttpParams } from '@angular/common/http';

export interface Post {
  id: number;
  userId: number;
  title: string;
}

getPostsByUser(userId: number): Observable<Post[]> {
  const params = new HttpParams()
    .set('userId', String(userId))
    .set('_limit', '5');

  const headers = new HttpHeaders({ 'Accept-Language': 'tr' });

  return this.http.get<Post[]>('https://jsonplaceholder.typicode.com/posts', { params, headers });
}

This call goes to /posts?userId=1&_limit=5, and an Accept-Language header is added to the request. Values are encoded properly for the URL, so question marks and ampersands are not your problem.

The one thing to watch out for: HttpParams and HttpHeaders are immutable. The set method doesn't change the existing object; it returns a new one. That is why we chain the calls. If you write params.set('page', '2'); without assigning the result, that parameter never makes it into the request.

Catching Errors: catchError and retry

The connection drops, the server crashes, someone mistypes the URL. HTTP requests don't always succeed, and we handle that with the RxJS operators we saw earlier:

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';

getUsers(): Observable<User[]> {
  return this.http.get<User[]>(this.apiUrl).pipe(
    retry(2),
    catchError((error: HttpErrorResponse) => {
      console.error('Could not load users:', error.status, error.message);
      return throwError(error);
    })
  );
}

retry(2) tries a failed request two more times, so three attempts in total. It is a lifesaver during brief network hiccups. Don't slap it on every request, though: retrying a POST can create the same record twice.

catchError catches the error and hands you an HttpErrorResponse. Its status field tells you what went wrong: 404 means the endpoint doesn't exist, 500 means something broke on the server, and 0 means the request never reached the server at all. catchError must return an observable. Here we log the error and rethrow it with throwError so the component hears about it too. If you prefer, you can return an empty list with of([]) and swallow the error entirely. Also note that we import operators from rxjs/operators, because Angular 12 projects come with RxJS 6.

To handle the error in the component, we pass an object to subscribe:

this.userService.getUsers().subscribe({
  next: users => this.users = users,
  error: () => this.errorMessage = 'The list cannot be loaded right now, please try again shortly.'
});

errorMessage is a simple string in the component, initialized to ''. You show it in the template with *ngIf, and the user isn't left staring at an empty screen wondering what happened.

Interceptors: The Gate Every Request Passes Through

Say the API wants a token on every request. Adding the header to each service method by hand is tedious, and one day someone will forget. That is exactly what an interceptor is for: every HTTP request leaving the app and every response coming back passes through it. Think of the security desk at a building's entrance; whoever walks in, they hand out the badge. Let's generate the skeleton with the CLI:

ng generate interceptor auth

Edit the generated file like this:

import { Injectable } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const token = localStorage.getItem('token');

    if (!token) {
      return next.handle(request);
    }

    const authRequest = request.clone({
      setHeaders: { Authorization: `Bearer ${token}` }
    });

    return next.handle(authRequest);
  }
}

The class implements the HttpInterceptor interface and has a single method: intercept. Because the incoming request is immutable, we can't write a header onto it directly; we take a copy with clone and add the header through setHeaders. next.handle passes the request on to the next interceptor in the chain, or to the server if none are left. If there is no token, we let the request continue untouched.

For the interceptor to run, you register it in AppModule:

import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { AuthInterceptor } from './auth.interceptor';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  providers: [
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

HTTP_INTERCEPTORS is an InjectionToken, and the useClass part is the same provider definition we saw earlier. The difference is the multi: true line. It says "this token is bound to a list, not a single value; add mine to the list." Forget it and Angular refuses to start, with an error saying it cannot mix multi providers and regular providers.

Global Error Handling with an Interceptor

An interceptor can touch the response on its way back, not just the outgoing request. Instead of handling errors like 401 or 500 in every service, let's gather them in one place:

import { Injectable } from '@angular/core';
import {
  HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          localStorage.removeItem('token');
          console.warn('Session expired, the user needs to sign in again.');
        } else if (error.status >= 500) {
          console.error('Something is wrong on the server.');
        }
        return throwError(error);
      })
    );
  }
}

next.handle(request) returns an observable that represents the response, and we attach catchError to it. In a real app, the console lines would be replaced by a notification or a redirect to the login page. We will cover navigating between pages in upcoming posts.

Add this interceptor to the providers array the same way, as the second entry. Order matters: requests pass through interceptors in array order, and responses come back in reverse order. Like nested matryoshka dolls; the outermost one opens first and closes last.

Moving the API Address into the Environment File

So far we have hard-coded the address in the service. When you want one server in development and another in production, that hard-coded string becomes a headache. Open src/environments/environment.ts:

export const environment = {
  production: false,
  apiUrl: 'https://jsonplaceholder.typicode.com'
};

Define the same field in environment.prod.ts with your production server's address. Then use the environment in the service instead of the hard-coded string:

import { environment } from '../environments/environment';

@Injectable({ providedIn: 'root' })
export class UserService {
  private apiUrl = `${environment.apiUrl}/users`;

  constructor(private http: HttpClient) { }
}

Always import from environment.ts, never from the .prod file. During a production build, the Angular CLI swaps this file for the prod version automatically. We will go into how that swap is configured when we get to builds.

A Small Experiment

Your turn. Write a PostService and use HttpParams to fetch the first user's posts from /posts?userId=1. Show the titles in a list and put a "Delete" button next to each row. When the button is clicked, send the delete request and remove the post from the array once it succeeds.

Then break it on purpose. Change the address to /postsx, look at the status value inside catchError, and show a readable error message on the screen. Finally, write a small LoggingInterceptor that logs the request's method and url to the console, register it with HTTP_INTERCEPTORS, then reload the page and watch the console.

Up to this point everything has happened on a single screen. The moment you want to click a user in the list and open their detail page, or show a completely different screen based on the URL in the address bar, the router comes into play. In the next post we will talk about routing in Angular: how we move between pages inside a single-page application.