Today I'm excited to announce @nestjs/http-client, a brand-new HTTP client for Nest built on the fetch API that ships with Node.js. It has no dependencies besides @nestjs/common and @nestjs/core, every method returns a Promise, and it comes with everything production code needs out of the box: timeouts, retries, interceptors, typed errors, and named clients.
In case you're not familiar with NestJS, it is a TypeScript Node.js framework that helps you build enterprise-grade efficient and scalable Node.js applications.
Let's dive right in! 🐈
Why a new HTTP client?
Almost every Nest application calls other services over HTTP. For years, the answer was @nestjs/axios, a thin wrapper that exposes Axios through Observables. In the meantime, Node.js has gained a fast, standards-based fetch, and most teams I talk to end up writing the same glue code around it over and over again: base URLs, JSON bodies, timeouts, retry loops, error mapping.
@nestjs/http-client is that glue, done once and done carefully. You configure it as a Nest module, inject it like any other provider, and simply await your requests. It requires Node.js 20.19 or later and works with both Nest 11 and Nest 12.
‼️ @nestjs/axios isn't going anywhere. It remains available, and both packages can be installed side by side while you migrate. ‼️
Getting started
First, install the package:
$ npm i --save @nestjs/http-clientThen register a client in the module that makes the requests:
import { Module } from '@nestjs/common';import { HttpClientModule } from '@nestjs/http-client';import { CatsService } from './cats.service.js';@Module({ imports: [ HttpClientModule.register({ baseUrl: 'https://api.example.com/v1', timeout: '5s', }), ], providers: [CatsService],})export class CatsModule {}And inject the HttpClient class:
import { Injectable } from '@nestjs/common';import { HttpClient } from '@nestjs/http-client';@Injectable()export class CatsService { constructor(private readonly http: HttpClient) {} async findAll(): Promise<Cat[]> { const { data } = await this.http.get<Cat[]>('/cats'); return data; }}No firstValueFrom(), no .pipe(). And the request goes exactly where you'd expect: https://api.example.com/v1/cats, with the /v1 prefix kept (unlike new URL('/cats', base), which would silently drop it).
Named clients
Real-world applications usually talk to several upstream services, each with its own base URL, credentials, and timeouts. To handle this, give each client a name:
@Module({ imports: [ HttpClientModule.register({ name: 'github', baseUrl: 'https://api.github.com', headers: { accept: 'application/vnd.github+json' }, }), ], providers: [ReposService],})export class ReposModule {}and inject it with the @InjectHttpClient() decorator:
@Injectable()export class ReposService { constructor(@InjectHttpClient('github') private readonly github: HttpClient) {}}Settings that every client shares, such as a user-agent header, a default timeout, or a logging interceptor, go into HttpClientModule.forRoot(), imported once in the root module. And if you need to build options from other providers (e.g., the ConfigService), registerAsync() and forRootAsync() accept useFactory, useClass, and useExisting, just like any other Nest module.
Requests that read like a controller
Request options mirror what a Nest controller reads from an incoming request: params fills the :name segments of the path, query builds the query string, and json sets the body.
async findAll(org: string): Promise<Repo[]> { const { data } = await this.github.get<Repo[]>('/orgs/:org/repos', { params: { org }, query: { type: 'public', sort: 'updated', topic: ['nest', 'typescript'] }, }); return data;}async createIssue(owner: string, repo: string, issue: CreateIssueDto): Promise<Issue> { const { data } = await this.github.post<Issue>('/repos/:owner/:repo/issues', { params: { owner, repo }, json: issue, }); return data;}Path parameters are URI-encoded, and a missing or unknown one throws before anything is sent, and so does a value such as .. that would move the request to another endpoint. Arrays in query repeat the key, Date values become ISO strings, and null and undefined values are skipped.
The responseType option controls how the body is read: 'auto' (the default, which parses JSON when the content type says so), 'json', 'text', 'arrayBuffer', 'stream' for a Node.js Readable, or 'response' for the raw web Response. The type of data follows it, so responseType: 'text' resolves to HttpResponse<string>.
Streaming a large upstream file through your API without buffering it in memory is just as simple:
@Get(':id/pdf')async download(@Param('id') id: string): Promise<StreamableFile> { const { data, headers } = await this.billing.get('/invoices/:id/pdf', { params: { id }, responseType: 'stream', }); return new StreamableFile(data, { type: headers.get('content-type') ?? 'application/pdf', });}Timeouts and retries, built in
fetch has no timeout of its own. With @nestjs/http-client, you can set one on each client, either in milliseconds or as a readable duration such as '500ms', '5s', or '2m'. An invalid value fails at startup (and not at 3 a.m. in production 🙃). To cancel a request, or to put a deadline on the whole call, pass an AbortSignal.
Retries are on by default, and deliberately conservative. A client makes up to 3 attempts for the methods HTTP defines as idempotent (GET, HEAD, OPTIONS, PUT, DELETE), after connection errors, timeouts, and 408, 429, 500, 502, 503, and 504 responses. Between attempts, it waits using exponential backoff with full jitter, and honors the Retry-After header.
POST and PATCH are never retried unless you explicitly opt in, because repeating them could, for example, charge a customer twice. When the upstream API supports idempotency keys, send one and opt in for that request:
const { data } = await this.payments.post<Charge>('/charges', { json: charge, headers: { 'idempotency-key': `charge-${order.id}` }, retry: { methods: ['POST'] },});Everything is configurable (number of attempts, backoff, jitter, methods, status codes, and a retryIf predicate), and settings are layered: forRoot(), then the client, then the individual request.
Errors you can actually act on
A failed request rejects with one of four typed errors, all extending the HttpClientError class:
HttpResponseError- the upstream answered with a non-2xx status (exposesstatus,headers, and the parsedbody)HttpTimeoutError- the timeout elapsed on the last attemptHttpNetworkError- the connection failed (DNS, refused or reset connection, TLS)HttpParseError- a successful response wasn't valid JSON, although it was expected to be
If one of these errors escapes a route handler, Nest answers 500 Internal Server Error. For your API's callers, though, a failing upstream is usually a 502 Bad Gateway, and one that doesn't answer in time is a 504 Gateway Timeout. That's exactly what the new toHttpException() function does. You can call it in a service, or map every upstream failure in one place with an exception filter:
import { ArgumentsHost, Catch } from '@nestjs/common';import { BaseExceptionFilter } from '@nestjs/core';import { HttpClientError, toHttpException } from '@nestjs/http-client';@Catch(HttpClientError)export class HttpClientErrorFilter extends BaseExceptionFilter { catch(error: HttpClientError, host: ArgumentsHost) { super.catch(toHttpException(error), host); }}Since Nest's built-in exception filter doesn't log an HttpException, toHttpException() also logs every failure that it turns into a 5xx, so upstream problems never vanish without a trace.
And speaking of logs: errors are safe to log. A message never includes the response body, query values in URLs are masked (GET https://api.example.com/users?email=***), and the url, headers, and body fields are non-enumerable, so loggers and JSON.stringify() leave them out.
Interceptors
An interceptor wraps every request a client sends. It receives the request and a next function, and returns the resulting web Response. The simplest form is a plain function, but when an interceptor needs other providers, you can write it as an injectable class:
@Injectable()export class GithubAuthInterceptor implements HttpClientInterceptor { constructor(private readonly tokens: GithubTokenService) {} async intercept(request: HttpRequest, next: HttpHandler): Promise<Response> { request.headers.set('authorization', `Bearer ${await this.tokens.getToken()}`); return next(request); }}HttpClientModule.register({ name: 'github', baseUrl: 'https://api.github.com', interceptors: [GithubAuthInterceptor],}),Interceptors run once per attempt, so a retry automatically gets a fresh token, and request.attempt tells them which attempt it is. This also makes them a great place to log upstream traffic.
Secure by default
The client guards against a few common (and costly) mistakes:
- A client with a
baseUrlonly sends requests to its origin. Its headers and interceptors carry that upstream's credentials, so a URL taken from user input or from an upstream response can never receive them. - Path parameters are always encoded, and a value such as
..throws instead of moving the request to another endpoint. - Credentials in URLs are refused. A URL such as
https://user:pass@example.comthrows, and so does a header value with a line break.
Testing
Testing code that calls an external API is easy as well. Pass a stub fetch to HttpClientModule.forRoot() and every client uses it, while keeping its base URL, headers, retry policy, and interceptors. The stub only has to return a standard Response:
const fetch = vi.fn<typeof globalThis.fetch>();const moduleRef = await Test.createTestingModule({ imports: [HttpClientModule.forRoot({ fetch }), ReposModule],}).compile();fetch.mockResolvedValueOnce(Response.json([{ name: 'nest' }]));Migrating from @nestjs/axios
For most projects, migrating is a mechanical process. Here are the most notable differences:
@nestjs/axios | @nestjs/http-client |
|---|---|
firstValueFrom(this.httpService.get(url)) | await this.http.get(url) |
One HttpService per importing module | Named clients, @InjectHttpClient(name) |
post(url, data) | post(url, { json: data }) |
params in the request config | query (params fills :name path segments) |
AxiosError with error.response?.status | HttpResponseError with error.status |
validateStatus: () => true | throwOnHttpError: false |
axiosRef.interceptors | interceptors, one function for request and response |
httpsAgent or proxy | dispatcher, an undici Agent or ProxyAgent |
No retries (unless you add axios-retry) | Retries on for idempotent methods, retry: false to opt out |
Prefer Observables? You can wrap any request with defer() from RxJS, or connect unsubscription to an AbortController so that switchMap() cancels in-flight requests. The documentation includes a ready-made helper for that.
Try it today!
$ npm i --save @nestjs/http-clientThe new HTTP client chapter in the official documentation covers everything above in depth, including proxies, TLS, redirects, and the full migration guide. The source code lives at github.com/nestjs/http-client. Give it a try, and please open an issue if anything gets in your way!
Happy coding! 🐈
Learn NestJS - Official NestJS Courses 📚
Level-up your NestJS and Node.js ecosystem skills in these incremental workshop-style courses, from the NestJS Creator himself, and help support the NestJS framework! 🐈🚀 The NestJS Fundamentals Course is now LIVE and 25% off for a limited time!
🎉 NEW - NestJS Course Extensions now live!
- NestJS Advanced Concepts Course now LIVE!
- NestJS Advanced Bundle (Advanced Architecture and Advanced Concepts) now 22% OFF!
- NestJS Microservices now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
- NestJS GraphQL Course (code-first & schema-first approaches) are now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
