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+jsonerrors normalized into a singleApiError, 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
HttpContextrather than mutable service state, so concurrent calls never interfere - Pluggable handlers —
ApiErrorHandlerandApiSuccessHandler; 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 aPaginatedResponse<T> - No UI dependency — peers are
@angular/core,@angular/common, andrxjs, 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 DIA 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:
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:
| Strategy | Result |
|---|---|
'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:
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:
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:
@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
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 a400just 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.
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:
@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
@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:
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.errorswas copied from the body without checking its shape. ASP.NET Core sends validation errors as a{ "Field": ["message"] }dictionary, so at runtimeerrorsheld 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 amapErroroption.
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-clientunder 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
| Area | Tooling |
|---|---|
| Library | Angular, TypeScript, RxJS 7, Angular Signals |
| Build | ng-packagr → Angular Package Format |
| Tests | Vitest — 118 unit tests |
| Quality | ESLint, Prettier, package-contents verification before publish |
| Release | release-please → automated versioning, changelog, and npm publish |
| Docs | Docusaurus, 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
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+jsonerrors normalised into oneApiErrorshape, 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/commonandrxjs. That's it.
Install
npm install @ismailza/ngx-api-clientSetup
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
apiErrorInterceptorbeforeretryInterceptor. It replaces theHttpErrorResponsewith a plainApiError, andretryInterceptoronly retriesHttpErrorResponses — 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/ordersprefix 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
ApiErrortoErrorHandler, which logs it. If your API puts sensitive data indetailorinstance, 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 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.