Back to Projects

ngx-api-client

An open-source Angular library that turns the ApiService everyone rewrites into a configurable, typed HTTP layer.

Current2026

Duration

Jul 2026 – Present

Contributors

2 contributors

Technologies

8+ tech

ngx-api-client

ngx-api-client is a typed, interceptor-driven HTTP layer for Angular, published on npm as @ismailza/ngx-api-client. It started as the api.service.ts I had rewritten in four consecutive Angular projects.

The problem

HttpClient is a good HTTP client, and that is genuinely all it claims to be. It gives you a request. It does not give you a policy:

  • Where does the base URL come from, and how does the API version get into it?
  • What turns a failed response into something a component can actually render?
  • Which requests are safe to retry, and how long do you wait before you do?
  • How does a global progress bar find out that anything is in flight?

Every application answers those four questions somewhere. Usually inside an ApiService that accumulates responsibility until nobody wants to touch it. By the fourth project I wasn't writing that file anymore — I was pasting it in, then re-fixing the same three bugs I'd already fixed elsewhere and never written down.

A pattern repeated four times isn't a pattern. It's a missing dependency.

What it gives you

  • Configurable versioning — URL segment, query parameter, header, or media type, overridable per request or disabled entirely
  • One error shape — RFC 9457 problem+json errors normalized into a single ApiError, whether the backend sends problem details, a plain-text proxy page, or nothing at all
  • Retry with exponential backoff and jitter — honours Retry-After, skips non-idempotent methods by default
  • Per-request options through HttpContext rather than mutable service state, so concurrent calls never interfere
  • Pluggable handlers — ApiErrorHandler and ApiSuccessHandler; the library never renders anything
  • Loading state as a counted signal, so concurrent requests don't flicker the progress bar off early
  • A typed pagination helper, getPage, returning a PaginatedResponse<T>
  • No UI dependency — peers are @angular/core, @angular/common, and rxjs, nothing else

Architecture

component / feature service
        │  api.get<T>('/orders', { version: 1 })
        ▼
ApiService ── resolves baseUrl + prefix + version + endpoint
        │      attaches per-request options to HttpContext
        ▼
HttpClient interceptor chain (registered by the app)
  apiErrorInterceptor    → normalizes failures into ApiError
  apiSuccessInterceptor  → dispatches success messages
  retryInterceptor       → backoff on retryable statuses
  (your auth interceptor)
        │
        ▼
ApiErrorHandler / ApiSuccessHandler  ← overridden by the app via DI

A provider function, provideApi, supplies configuration through injection tokens. ApiService resolves the URL and attaches per-request options to HttpContext. Three standalone functional interceptors read that context. Handlers are abstract classes the application overrides via DI, keeping presentation out of the HTTP layer entirely.

Design decisions

Versioning is a strategy, not string concatenation

The copy-pasted version hard-coded ${baseUrl}/api/v1${endpoint}. That works until a backend versions by header, query parameter, media type, or release date. So versioning became configurable, with the same application code producing any of them:

ts
provideApi({
  baseUrl: 'https://api.example.com',
  prefix: 'api',        // '' or false to omit
  version: 2,
  versioning: 'url',    // 'query-param' | 'header' | 'media-type' | false
});

The same GET /orders call then resolves to:

StrategyResult
'url' (default)/api/v2/orders
'query-param'/api/orders?v=2
'header'/api/orders, plus X-API-Version: 2
'media-type'Accept: application/vnd.api.v2+json
false/api/orders, no version sent at all

Two rules turned out to matter more than expected. A header or query parameter the caller set explicitly is never overwritten by the strategy — a library that silently clobbers explicit caller input is a library you cannot debug. And version accepts strings as well as numbers, which only became obvious after integrating with a backend that versioned by date:

ts
provideApi({
  baseUrl: 'https://api.example.com',
  version: '2024-01-01',
  versioning: { strategy: 'header', headerName: 'Api-Version' },
});

Typing it as number — what the copy-pasted version implicitly assumed — would have made the abstraction useless for the exact API that motivated building it.

One error shape, whatever the backend sends

Failures arrive in at least three flavours: a well-behaved RFC 9457 problem+json body, a plain-text page from a proxy that answered before the app ever saw the request, and a network drop where the status is simply 0. Components shouldn't need three code paths for that. Everything is normalized:

ts
interface ApiError {
  type: string;        // RFC 9457 problem type URI
  title?: string;      // 'Bad Request'
  status: number;      // 0 for a network failure
  detail: string;      // safe to show the user
  instance?: string;   // path that produced it
  code?: string;       // machine-readable, e.g. 'VALIDATION_ERROR'
  timestamp: string;
  traceId?: string;    // from the body, else the X-Trace-Id header
  errors?: { field: string; message: string }[];
}

Branch on code, never on detail. detail is prose meant for a human — it gets reworded, translated, and A/B tested, and every if (error.detail === 'Invalid credentials') silently stops working the day somebody rephrases it. And traceId falls back to the X-Trace-Id response header, because nine times out of ten the useful correlation ID is in the headers while the error body is empty.

The library renders nothing

A toast notification was the easiest feature to add and the hardest to leave out on purpose. The moment a library renders anything, it has an opinion about your design system, your i18n setup, and your accessibility strategy — three things it cannot know. So it ships handlers instead:

ts
@Injectable()
export class ToastApiErrorHandler extends ApiErrorHandler {
  private readonly toast = inject(MyToastService);
  private readonly router = inject(Router);

  override handle(error: ApiError): void {
    this.toast.error(error.detail, { title: error.title });
    if (error.status === 403) {
      this.router.navigate(['/forbidden']);
    }
  }
}

The HTTP layer reports problems; the application decides how users experience them. The practical payoff is the dependency list: nothing in your HTTP layer has any business pulling a UI kit into your bundle.

One caveat lives in the README rather than hidden: the fallback handler passes the whole ApiError to Angular's own ErrorHandler, which logs it. If your API puts sensitive data in detail or instance, register your own handler. Defaults should be safe, and where they can't be, they should say so.

You register the interceptors, not the library

ts
provideHttpClient(
  withInterceptors([apiErrorInterceptor, apiSuccessInterceptor, retryInterceptor]),
);

This is the decision reviewers questioned most — it looks like boilerplate the library should absorb. It isn't, because order matters and only the application knows what else is in the chain. apiErrorInterceptor goes before retryInterceptor: it replaces the HttpErrorResponse with a plain ApiError, and retryInterceptor only retries HttpErrorResponses, so nesting them the other way round means retry silently never fires. Outermost, it also reports one failure per operation rather than one per attempt. An auth interceptor goes last, inside retryInterceptor, so each retried attempt is signed with a fresh token.

If the library injected interceptors at a position it chose, that position would be wrong in some application, and debugging it would mean reading my source.

Retry is harder than retry(3)

  • Statuses — only 408, 429, 500, 502, 503, 504. Retrying a 400 just asks the server to reject you four times.
  • Backoff — initialDelay * multiplier^(attempt-1), plus 0–30% jitter.
  • Retry-After — honoured on 429. The server has told you exactly when to come back; your backoff curve is a guess, the header isn't.
  • POST — not retried unless the caller opts in, since replaying it can create two orders.

Jitter isn't decoration. Without it, every client that hit a struggling backend at the same moment retries at the same moment, and again two seconds later — a synchronized load test against a server that is already failing.

Two smaller decisions

Per-request configuration travels through HttpContext, not service state. A mutable flag on the service that the next interceptor reads is a race condition waiting for two concurrent calls.

ts
this.api.get<LegacyOrder>('/orders', { version: 1 });
this.api.get<Health>('/health', { prefix: false, version: false });
this.api.post('/analytics/event', payload, {
  retry: false,
  skipErrorHandler: true,
  showLoader: false,
});

Loading state is a counter, not a boolean. Three requests start, the fastest finishes, and a boolean flickers the progress bar off while two are still in flight. A counted signal doesn't:

ts
@Injectable({ providedIn: 'root' })
export class ApiLoadingService {
  private readonly activeRequests = signal(0);
  readonly loading = computed(() => this.activeRequests() > 0);
}

Five lines, and the single most-copied snippet from every version of this file I ever wrote.

Usage

ts
@Injectable({ providedIn: 'root' })
export class OrderService {
  private readonly api = inject(ApiService);

  list(page: number, size: number): Observable<PaginatedResponse<Order>> {
    return this.api.getPage<Order>('/orders', page, size);
  }

  get(id: string): Observable<Order> {
    return this.api.get<Order>(`/orders/${id}`);
  }

  create(order: OrderRequest): Observable<Order> {
    return this.api.post<Order>('/orders', order);
  }
}

Tested against a real backend

Unit tests prove the library does what I think it does. They can't prove that what I think matches a real backend. So I built ngx-libs-demo: an Angular 22 client and an ASP.NET Core 10 API, where every feature call goes through ApiService and authentication comes from ngx-auth-client.

The interceptor order recommended above is the demo's actual chain, with the auth interceptor innermost:

ts
withInterceptors([
  apiErrorInterceptor,   // outermost: one ApiError per operation, reported once
  apiSuccessInterceptor, // success toast for mutating calls
  retryInterceptor,      // retries the raw HttpErrorResponse
  authTokenInterceptor,  // innermost: each retried attempt gets a fresh token
]);

The API speaks the library's contract — problem+json errors with a machine-readable code, URL versioning, and the page shape getPage() expects — and a Playground page triggers the hard cases on demand: endpoints that fail a set number of times before succeeding, 429 with Retry-After, deliberately slow responses, and the same resource served differently under /api/v1 and /api/v2.

It also did what a demo should do: it found problems. Two are recorded in its library-feedback notes for 0.3.0, each with a workaround in the demo while the fix belongs in the library:

  • The provideApi() JSDoc example ordered the interceptors the wrong way round — contradicting the README, and with that order nothing is ever retried. The demo has a test that fails with the JSDoc order.
  • ApiError.errors was copied from the body without checking its shape. ASP.NET Core sends validation errors as a { "Field": ["message"] } dictionary, so at runtime errors held an object while its type promised an array. The demo's server flattens errors to [{ field, message }]; in the library, the fix is to normalise that common shape or accept a mapError option.

Neither would have shown up in the library's own test suite, because both live at the boundary between the library and something it doesn't control.

Compatibility, verified rather than claimed

The peer dependency range is deliberately bounded. A new Angular major is added to it only once it has been validated, so the supported range is a tested claim rather than an assumption — see the compatibility documentation for the versions currently covered.

That validation runs automatically. CI packs the library once and installs that tarball — the packaged npm artifact, not the source — into a throwaway consumer application for every supported Angular major, on multiple Node versions. Each combination runs three checks: a type-check against that version's Angular types, a production ng build (which is what actually exercises the Angular linker over the shipped partial declarations), and a runtime test of the injector and the exports map. The matrix is derived from the library's own @angular/core peer range, so it cannot drift from what the package claims to support.

Results

  • Published to npm as @ismailza/ngx-api-client under MIT, with releases automated by release-please
  • Every supported Angular version validated in CI on each push, against the packaged npm artifact rather than the source
  • 118 unit tests across the service, interceptors, handlers, and provider
  • Exercised end to end against an ASP.NET Core backend in ngx-libs-demo, alongside ngx-auth-client
  • Full documentation site at ismailza.github.io/ngx-api-client, deployed from CI
  • Zero runtime dependencies beyond tslib

Project setup

AreaTooling
LibraryAngular, TypeScript, RxJS 7, Angular Signals
Buildng-packagr → Angular Package Format
TestsVitest — 118 unit tests
QualityESLint, Prettier, package-contents verification before publish
Releaserelease-please → automated versioning, changelog, and npm publish
DocsDocusaurus, deployed to GitHub Pages from CI

What I'd tell myself before starting

Don't extract after the first implementation. Extract after the fourth. Three implementations taught me which parts actually varied — the version transport, the error presentation — and which never did. Abstracting after one would have produced configuration options nobody needed while hard-coding the things that mattered.

Write the README before the code you're unsure about. Every section I struggled to explain was a section where the API was wrong. The interceptor-ordering paragraph took three rewrites, and the third is what convinced me not to auto-register them.

Build a real app on it before calling it done. The demo found two issues that 118 unit tests never could, because the tests only ever talked to the library's own assumptions.

The full story is in I Stopped Copy-Pasting the Same Angular ApiService. For the authentication side of the same stack, see ngx-auth-client.

ngx-api-client

A typed, interceptor-driven HTTP layer for Angular.

Documentation · npm · Quick start · API reference

npm version npm downloads Documentation CI Release License

HttpClient gives you a request. It doesn't give you a policy — where the base URL comes from, how a failed response becomes something your components can render, which requests are safe to retry, or how a loading indicator knows anything is in flight. Most apps end up re-solving all four in an ad-hoc ApiService. This is that service, extracted and made configurable.

  • Configurable versioning — URL segment, query parameter, header or media type; or turned off entirely — overridable per request
  • RFC 9457 problem+json errors normalised into one ApiError shape, whatever the backend returns
  • Retry with exponential backoff, jitter and Retry-After, skipping non-idempotent methods
  • Per-request options carried to interceptors through HttpContext, not globals
  • Pluggable presentation — the library never renders anything; you decide what an error looks like
  • Loading signal for a global progress bar
  • No UI dependency. Peers are @angular/core, @angular/common and rxjs. That's it.

Install

npm install @ismailza/ngx-api-client

Setup

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
  provideApi,
  apiErrorInterceptor,
  apiSuccessInterceptor,
  retryInterceptor,
} from '@ismailza/ngx-api-client';

export const appConfig: ApplicationConfig = {
  providers: [
    provideApi({
      baseUrl: 'https://api.example.com',
      prefix: 'api', // {baseUrl}/api/...  — '' or false to omit
      version: 1,
      versioning: 'url', // 'query-param' | 'header' | 'media-type' | false
      retry: { maxRetries: 3, initialDelay: 1000 },
    }),
    provideHttpClient(
      withInterceptors([apiErrorInterceptor, apiSuccessInterceptor, retryInterceptor]),
    ),
  ],
};

Interceptors are registered by you, not by provideApi(), because order matters and only you know what else is in the chain. The first interceptor in the array is the outermost one:

  • Keep apiErrorInterceptor before retryInterceptor. It replaces the HttpErrorResponse with a plain ApiError, and retryInterceptor only retries HttpErrorResponses — nest them the other way round and retry silently never fires. Outermost, it also reports one failure per operation rather than one per attempt.
  • Put an auth/bearer-token interceptor last, inside retryInterceptor, so each retried attempt is signed with a fresh token.

Usage

@Injectable({ providedIn: 'root' })
export class OrderService {
  private readonly api = inject(ApiService);

  list(page: number, size: number): Observable<PaginatedResponse<Order>> {
    return this.api.getPage<Order>('/orders', page, size);
  }

  get(id: string): Observable<Order> {
    return this.api.get<Order>(`/orders/${id}`);
  }

  create(order: OrderRequest): Observable<Order> {
    return this.api.post<Order>('/orders', order);
  }
}

GET /orders with version: 1 resolves to https://api.example.com/api/v1/orders?page=0&size=20.

URL shape and versioning

A URL is {baseUrl} + an optional {prefix} segment + an optional version segment + {endpoint}. Both middle pieces are yours to configure — or remove.

provideApi({ baseUrl: 'https://api.example.com', prefix: 'api', version: 2 });
// → https://api.example.com/api/v2/orders

prefix is a static path segment (default 'api'); set it to '' or false when the API is served from the root. versioning picks how the version travels:

versioning Result for GET /orders
'url' (default) /api/v2/orders
{ strategy: 'url', prefix: '' } /api/2/orders
'query-param' /api/orders?v=2
{ strategy: 'query-param', parameterName: 'ver' } /api/orders?ver=2
'header' /api/orders + X-API-Version: 2
{ strategy: 'header', headerName: 'Api-Version' } /api/orders + Api-Version: 2
'media-type' Accept: application/vnd.api.v2+json
false /api/orders, no version sent

For 'media-type', mediaType is a template whose {version} placeholders are substituted — default 'application/vnd.api.v{version}+json'.

version accepts strings too, for date- or label-based schemes:

provideApi({
  baseUrl: 'https://api.example.com',
  version: '2024-01-01',
  versioning: { strategy: 'header', headerName: 'Api-Version' },
});

A header or query parameter the caller sets explicitly is never overwritten by the versioning strategy, and a version placed in the URL is percent-encoded. With versioning: false there is no strategy to carry a version, so a per-request version is ignored.

Per-request options

Every method takes an optional ApiRequestOptions:

// Pin this call to an older API version
this.api.get<LegacyOrder>('/orders', { version: 1 });

// Hit an unversioned endpoint outside the API prefix: /health
this.api.get<Health>('/health', { prefix: false, version: false });

// Fire-and-forget: no retry, no global error handler, no loading indicator
this.api.post('/analytics/event', payload, {
  retry: false,
  skipErrorHandler: true,
  showLoader: false,
});

// Custom success message; or none at all
this.api.put(`/orders/${id}`, order, { successMessage: 'Order updated' });
this.api.delete(`/orders/${id}`, { successMessage: false });

These travel to the interceptors via HttpContext, so they stay scoped to the one request instead of mutating shared state.

Error handling

Every failure — a problem+json body, a plain-text proxy error page, or a network drop that never reached the server — arrives as the same ApiError:

interface ApiError {
  type: string; // RFC 9457 problem type URI
  title?: string; // 'Bad Request'
  status: number; // 0 for a network failure
  detail: string; // safe to show the user
  instance?: string; // path that produced it
  code?: string; // machine-readable, e.g. 'VALIDATION_ERROR'
  timestamp: string;
  traceId?: string; // body, else the X-Trace-Id header
  errors?: { field: string; message: string }[];
}

Branch on code, never on detail text.

The library will not render this for you. provideApi() registers a fallback that forwards to Angular's ErrorHandler and nothing more. To show something, provide your own:

@Injectable()
export class ToastApiErrorHandler extends ApiErrorHandler {
  private readonly toast = inject(MyToastService);
  private readonly router = inject(Router);

  override handle(error: ApiError): void {
    this.toast.error(error.detail, { title: error.title });

    if (error.status === 403) {
      this.router.navigate(['/forbidden']);
    }
  }
}

// in appConfig.providers
{ provide: ApiErrorHandler, useClass: ToastApiErrorHandler }

Note: the fallback hands the whole ApiError to ErrorHandler, which logs it. If your API puts sensitive data in detail or instance, register your own handler rather than relying on the default.

ApiSuccessHandler works the same way and defaults to discarding the message.

Retry

Applied only to responses whose status is in retryableStatuses (408, 429, 500, 502, 503, 504 by default):

Behaviour Detail
Backoff initialDelay * multiplier^(attempt-1), plus 0–30% jitter
Retry-After Honoured on 429, in seconds or as an HTTP date — overrides the backoff
POST Not retried unless the request opts in, since it isn't idempotent
Opt out retry: false per request, or globally in provideApi()

Loading state

ApiLoadingService counts in-flight requests and exposes a signal, so concurrent calls don't flicker the indicator off early:

@Component({
  template: `@if (loading.loading()) {
    <my-progress-bar />
  }`,
})
export class AppShell {
  protected readonly loading = inject(ApiLoadingService);
}

Exclude a request with showLoader: false.

Configuration

Option Default Description
baseUrl required API root; a trailing slash is trimmed
prefix 'api' Static path segment; ''/false omits it
version 1 Version value, number or string
versioning 'url' Strategy name, config object, or false to disable
retry { maxRetries: 3, initialDelay: 1000, multiplier: 2 } false disables retry globally
defaultShowLoader true Whether requests track loading state
defaultShowSuccessMessage true Success message on mutating methods
defaultSuccessMessages per-method English defaults Merged over the built-ins

Every option falls back to its default when omitted or when passed explicitly as undefined, so building the config from optional sources (prefix: environment.apiPrefix) is safe.

Success messages are plain strings so the package carries no i18n dependency — pass translated values into defaultSuccessMessages, or handle wording in your own ApiSuccessHandler.

Compatibility

Requirement Supported
Angular 17 · 18 · 19 · 20 · 21 · 22
RxJS ^7.4.0
Peer dependencies @angular/core, @angular/common, rxjs — nothing else

Every Angular major above is verified on each push, against the packed tarball rather than the source. CI installs the package into a throwaway consumer application per version and runs three checks: a type-check against that version's Angular types, a production ng build — which is what exercises the Angular linker over the shipped partial declarations — and a runtime test of the injector and the exports map. Each combination runs on both Node 22 and 24.

The peer range is bounded (>=17.0.0 <23.0.0) deliberately: a new Angular major is added only once it has been validated, so the supported range is a tested claim rather than an assumption.

Contributing

Contributions are welcome! Please read our Contributing Guide before opening an issue or pull request.

Code of Conduct

Please read our Code of Conduct to help us maintain a welcoming and inclusive community.

License

MIT © Ismail ZAHIR

Support the Project

If you find this library useful, consider giving it a ⭐ on GitHub. It helps others discover the project and motivates future development.

MIT LicenseView on GitHub
MIT License

Copyright (c) 2026 Ismail ZAHIR

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

More Projects

ngx-auth-client
2026Current
A published npm package that exposes Angular authentication as signals instead of getters, ships Keycloak and HTTP adapters behind a provider-agnostic port, and keeps tokens out of localStorage entirely.
AngularTypeScriptKeycloak+7
N

ngx-libs-demo

2026
An Angular 22 client and an ASP.NET Core 10 API built to test my two Angular libraries end to end: home-made JWT auth with rotating refresh cookies, role-based guards, retries with backoff and problem+json errors — and the place where both libraries meet a backend they don't control.
AngularASP.NET CoreASP.NET Core Identity+6