JSON in Angular: HttpClient, Interfaces, and RxJS

Last updated:

Angular's HttpClient is the standard way to fetch and send JSON data in Angular applications. Unlike the browser's native fetch, HttpClient automatically parses JSON responses, integrates with RxJS observables, and provides built-in support for interceptors, typed responses, and error handling. This guide covers everything from initial setup to advanced RxJS patterns and Angular 17+ standalone APIs.

Setting Up HttpClientModule

Before using HttpClient, you must provide it in your application. In Angular 17+ standalone apps, use provideHttpClient() in bootstrapApplication. In module-based apps, import HttpClientModule in AppModule.

// Angular 17+ standalone (main.ts)
import { bootstrapApplication } from '@angular/platform-browser'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { AppComponent } from './app/app.component'

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      // optional: add functional interceptors here
      // withInterceptors([authInterceptor, loggingInterceptor])
    ),
  ],
})

// --- OR --- Module-based apps (app.module.ts)
import { NgModule } from '@angular/core'
import { HttpClientModule } from '@angular/common/http'
import { BrowserModule } from '@angular/platform-browser'
import { AppComponent } from './app.component'

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

GET JSON with HttpClient

HttpClient.get<T>(url) sends a GET request and returns an Observable<T> that emits the parsed JSON body. Angular sets Accept: application/json by default and deserializes the response automatically.

// product.service.ts
import { Injectable, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs'

export interface Product {
  id: number
  name: string
  price: number
  category: string
}

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient)  // Angular 17+ inject()
  private baseUrl = '/api'

  // GET all products
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(`${this.baseUrl}/products`)
  }

  // GET single product by ID
  getProduct(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.baseUrl}/products/${id}`)
  }
}

// product-list.component.ts — using subscribe()
import { Component, OnInit, inject } from '@angular/core'
import { ProductService, Product } from './product.service'

@Component({
  selector: 'app-product-list',
  standalone: true,
  template: `
    <ul>
      <li *ngFor="let p of products">{{ p.name }} — {{ p.price | currency }}</li>
    </ul>
  `,
})
export class ProductListComponent implements OnInit {
  products: Product[] = []
  private productService = inject(ProductService)

  ngOnInit() {
    this.productService.getProducts().subscribe({
      next: (products) => (this.products = products),
      error: (err) => console.error('Failed to load products', err),
    })
  }
}

Prefer the async pipe over manual subscription — it automatically unsubscribes when the component is destroyed, preventing memory leaks:

// Using async pipe — no manual subscribe/unsubscribe needed
import { Component, inject } from '@angular/core'
import { AsyncPipe, NgFor, CurrencyPipe } from '@angular/common'
import { ProductService } from './product.service'

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [AsyncPipe, NgFor, CurrencyPipe],
  template: `
    @if (products$ | async; as products) {
      <ul>
        @for (product of products; track product.id) {
          <li>{{ product.name }} — {{ product.price | currency }}</li>
        }
      </ul>
    }
  `,
})
export class ProductListComponent {
  products$ = inject(ProductService).getProducts()
}

POST JSON Data

HttpClient.post<T>(url, body) serializes the body to JSON and sets Content-Type: application/json automatically. You do not need to call JSON.stringify.

// product.service.ts — POST, PUT, PATCH, DELETE
import { Injectable, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs'
import { Product } from './product.model'

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient)
  private baseUrl = '/api/products'

  // POST — create a new product
  createProduct(data: Omit<Product, 'id'>): Observable<Product> {
    return this.http.post<Product>(this.baseUrl, data)
    // Angular sets: Content-Type: application/json
    // and serializes data with JSON.stringify automatically
  }

  // PUT — full replacement
  replaceProduct(id: number, data: Omit<Product, 'id'>): Observable<Product> {
    return this.http.put<Product>(`${this.baseUrl}/${id}`, data)
  }

  // PATCH — partial update
  updateProduct(id: number, data: Partial<Product>): Observable<Product> {
    return this.http.patch<Product>(`${this.baseUrl}/${id}`, data)
  }

  // DELETE
  deleteProduct(id: number): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`)
  }
}

// In a component:
this.productService.createProduct({ name: 'Widget', price: 9.99, category: 'tools' })
  .subscribe({
    next: (created) => console.log('Created:', created.id),
    error: (err) => console.error(err),
  })

Typing Responses with Interfaces

TypeScript interfaces describe the shape of JSON responses. Passing the interface as a generic type to HttpClient methods gives you full type safety and IDE autocompletion.

// models/product.model.ts

// Simple entity interface
export interface Product {
  id: number
  name: string
  price: number
  category: string
  createdAt: string  // ISO 8601 — JSON has no Date type
}

// Paginated API response wrapper
export interface PagedResponse<T> {
  data: T[]
  total: number
  page: number
  pageSize: number
}

// Nested relationships
export interface OrderDetail {
  id: number
  status: 'pending' | 'shipped' | 'delivered' | 'cancelled'
  items: Array<{
    productId: number
    quantity: number
    unitPrice: number
  }>
  customer: {
    id: number
    name: string
    email: string
  }
}

// Usage in service
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'

@Injectable({ providedIn: 'root' })
export class OrderService {
  private http = inject(HttpClient)

  // Typed paginated response
  getOrders(page: number): Observable<PagedResponse<OrderDetail>> {
    return this.http.get<PagedResponse<OrderDetail>>(
      `/api/orders?page=${page}`
    )
  }

  // Extract nested data with map()
  getOrderItems(orderId: number) {
    return this.http.get<OrderDetail>(`/api/orders/${orderId}`).pipe(
      map((order) => order.items)
      // returns Observable<Array<{ productId, quantity, unitPrice }>>
    )
  }
}

RxJS Operators: map, catchError, switchMap

RxJS operators transform and compose Observables returned by HttpClient. Use pipe() to chain them.

import { HttpClient, HttpErrorResponse } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import {
  Observable, Subject, EMPTY, throwError, of
} from 'rxjs'
import {
  map, catchError, switchMap, debounceTime,
  distinctUntilChanged, retry, tap, shareReplay
} from 'rxjs/operators'

@Injectable({ providedIn: 'root' })
export class SearchService {
  private http = inject(HttpClient)

  // map — transform response shape
  getProductNames(): Observable<string[]> {
    return this.http.get<{ id: number; name: string }[]>('/api/products').pipe(
      map((products) => products.map((p) => p.name))
    )
  }

  // catchError — handle errors gracefully
  getProductsSafe(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products').pipe(
      catchError((err: HttpErrorResponse) => {
        if (err.status === 404) return of([])       // empty array on 404
        if (err.status === 0)  return of([])       // network error
        return throwError(() => err)               // rethrow others
      })
    )
  }

  // retry — automatic retry on transient failures
  getProductsWithRetry(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products').pipe(
      retry({ count: 3, delay: 1000 })   // retry up to 3 times, 1s apart
    )
  }

  // switchMap — cancel previous request on new emission (search autocomplete)
  private searchTerm$ = new Subject<string>()

  search$ = this.searchTerm$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) =>
      term.length < 2
        ? EMPTY
        : this.http.get<Product[]>('/api/search', { params: { q: term } }).pipe(
            catchError(() => of([]))
          )
    )
  )

  search(term: string) {
    this.searchTerm$.next(term)
  }

  // shareReplay — cache and share among multiple subscribers
  private categories$ = this.http.get<string[]>('/api/categories').pipe(
    shareReplay(1)  // replay the latest value to new subscribers
  )

  getCategories(): Observable<string[]> {
    return this.categories$
  }

  // tap — side effects without transformation (logging)
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products').pipe(
      tap((products) => console.log('Fetched', products.length, 'products')),
      map((products) => products.filter((p) => p.price > 0))
    )
  }
}

HTTP Interceptors for JSON Headers

Interceptors run for every HTTP request and response. They are the correct place to add global headers like Authorization, log requests, or handle 401 errors centrally.

// auth.interceptor.ts — functional interceptor (Angular 15+)
import { HttpInterceptorFn } from '@angular/common/http'
import { inject } from '@angular/core'
import { AuthService } from './auth.service'

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService)
  const token = auth.getToken()

  if (!token) return next(req)

  const authReq = req.clone({
    setHeaders: {
      'Authorization': `Bearer ${token}`,
      // HttpClient already sets Content-Type: application/json for post/put/patch
      // but you can force it here for all requests:
      // 'Content-Type': 'application/json',
    },
  })

  return next(authReq)
}

// logging.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http'
import { tap } from 'rxjs/operators'

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  console.log(`[${req.method}] ${req.url}`)
  return next(req).pipe(
    tap({
      next: (event) => console.log('Response:', event),
      error: (err) => console.error('Error:', err.status, err.url),
    })
  )
}

// error.interceptor.ts — global 401 redirect
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http'
import { inject } from '@angular/core'
import { Router } from '@angular/router'
import { catchError, throwError } from 'rxjs'

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router)
  return next(req).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status === 401) {
        router.navigate(['/login'])
      }
      return throwError(() => err)
    })
  )
}

// Register in main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import { provideHttpClient, withInterceptors } from '@angular/common/http'
import { authInterceptor, loggingInterceptor, errorInterceptor } from './interceptors'

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withInterceptors([authInterceptor, loggingInterceptor, errorInterceptor])
    ),
  ],
})

For class-based interceptors (legacy Angular modules), implement HttpInterceptor and provide with HTTP_INTERCEPTORS:

// Class-based interceptor — for NgModule-based apps
import { Injectable } from '@angular/core'
import {
  HttpInterceptor, HttpRequest, HttpHandler, HttpEvent
} from '@angular/common/http'
import { Observable } from 'rxjs'

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const token = localStorage.getItem('token')
    if (!token) return next.handle(req)

    const authReq = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    })
    return next.handle(authReq)
  }
}

// app.module.ts
import { HTTP_INTERCEPTORS } from '@angular/common/http'
providers: [
  { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
]

Angular 17+ Standalone Components

Angular 17+ promotes standalone components as the default. The inject() function replaces constructor injection, and provideHttpClient() replaces HttpClientModule.

// main.ts — standalone bootstrap
import { bootstrapApplication } from '@angular/platform-browser'
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http'
import { AppComponent } from './app/app.component'
import { authInterceptor } from './app/interceptors/auth.interceptor'

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(
      withFetch(),                          // use browser Fetch API under the hood
      withInterceptors([authInterceptor]),  // functional interceptors
    ),
  ],
})

// product.component.ts — standalone component with inject()
import { Component, inject, signal } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { AsyncPipe, NgFor, NgIf } from '@angular/common'
import { catchError, of } from 'rxjs'

interface Product {
  id: number
  name: string
  price: number
}

@Component({
  selector: 'app-products',
  standalone: true,
  imports: [AsyncPipe, NgFor, NgIf],
  template: `
    @if (error()) {
      <p class="error">{{ error() }}</p>
    }
    @if (products$ | async; as products) {
      <ul>
        @for (product of products; track product.id) {
          <li>{{ product.name }} — ${{ product.price }}</li>
        }
      </ul>
    }
  `,
})
export class ProductsComponent {
  // inject() — no constructor needed
  private http = inject(HttpClient)

  // Signals for local state (Angular 17+)
  error = signal<string | null>(null)

  products$ = this.http.get<Product[]>('/api/products').pipe(
    catchError((err) => {
      this.error.set('Failed to load products. Please try again.')
      return of([])
    })
  )
}

// Using toSignal() for signal-based reactive data
import { toSignal } from '@angular/core/rxjs-interop'

@Component({ standalone: true, template: `
  @for (p of products(); track p.id) {
    <li>{{ p.name }}</li>
  }
` })
export class ProductsSignalComponent {
  private http = inject(HttpClient)

  // Convert Observable to Signal — no subscribe, no async pipe needed
  products = toSignal(
    this.http.get<Product[]>('/api/products'),
    { initialValue: [] }
  )
}

Key Terms

Observable
A lazy push-based data stream from RxJS. HttpClient methods return Observables that emit the HTTP response once. Observables do nothing until subscribed — unlike Promises, an un-subscribed http.get() never fires the request.
HttpClient
Angular's built-in HTTP service. Wraps the browser's XMLHttpRequest (or fetch with withFetch()), adds automatic JSON serialization/deserialization, interceptor support, typed generics, and progress events.
Interceptor
A middleware function (or class) that runs for every HTTP request and response. Used to add headers, log activity, handle global errors, or transform request/response bodies without modifying individual service methods.
RxJS operator
A pure function that takes an Observable and returns a new Observable with modified behavior. Operators like map, catchError, switchMap, and retry are composed with pipe() to build declarative data pipelines.
async pipe
An Angular template pipe (| async) that subscribes to an Observable or Promise, renders the latest emitted value, and automatically unsubscribes when the component is destroyed. The preferred way to consume HttpClient Observables in templates.

FAQ

How do I fetch JSON from an API in Angular?

Inject HttpClient into a service and call this.http.get<T>(url). Angular parses the JSON response automatically — no response.json() needed. The method returns an Observable<T>. In the component, either subscribe: .subscribe(data => this.data = data), or use the async pipe in the template: data$ | async.

Do I need to call response.json() with Angular HttpClient?

No. HttpClient automatically deserializes JSON responses. The Observable emits a fully parsed JavaScript object. This differs from the browser's fetch API which returns a Response object requiring .json(). Angular also sets Accept: application/json on every request by default.

How do I type HTTP responses in Angular?

Pass a TypeScript interface as the generic parameter: this.http.get<Product>(url) or this.http.get<Product[]>(url). Define an interface matching the JSON shape. For wrapped responses, create a generic wrapper: interface ApiResponse<T> { data: T; total: number } then this.http.get<ApiResponse<Product[]>>(url).

How do I POST JSON data with Angular HttpClient?

Call this.http.post<ResponseType>(url, body). Angular serializes the body object to JSON and sets Content-Type: application/json automatically. Do not call JSON.stringify on the body yourself. The same applies to put() and patch().

What RxJS operators are useful for HTTP JSON requests in Angular?

The most important operators are: map (transform response shape), catchError (handle errors, return fallback), switchMap (cancel previous requests — search autocomplete), retry (automatic retry on failure), tap (logging without transforming), and shareReplay(1) (cache and multicast). Compose them in pipe().

How do I handle HTTP errors when fetching JSON in Angular?

Use catchError in the pipe() chain. It receives an HttpErrorResponse with status, statusText, and error (the parsed JSON error body). Return a safe fallback with of([]) or EMPTY, or rethrow with throwError(() => err). For global error handling across all requests, use an HttpInterceptor.

What is an HttpInterceptor and when should I use one?

An HttpInterceptor intercepts every HTTP request and response. Use interceptors to add Authorization headers globally, log all requests, handle 401 redirects, or retry on failure — without modifying individual services. In Angular 17+ standalone apps, use functional interceptors with provideHttpClient(withInterceptors([myInterceptor])).

How does inject(HttpClient) work in Angular 17+ standalone components?

inject(HttpClient) replaces constructor injection in Angular 17+ standalone components. Call it in the class body (not inside a method): private http = inject(HttpClient). It must run in an injection context — during class instantiation. Register HttpClient via provideHttpClient() in bootstrapApplication()'s providers array.

Further reading and primary sources