Meet the New @nestjs/http-client.

Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

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-client

Then 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 (exposes status, headers, and the parsed body)
  • HttpTimeoutError - the timeout elapsed on the last attempt
  • HttpNetworkError - 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 baseUrl only 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.com throws, 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 moduleNamed clients, @InjectHttpClient(name)
post(url, data)post(url, { json: data })
params in the request configquery (params fills :name path segments)
AxiosError with error.response?.statusHttpResponseError with error.status
validateStatus: () => truethrowOnHttpError: false
axiosRef.interceptorsinterceptors, one function for request and response
httpsAgent or proxydispatcher, 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-client

The 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
#NodeJS

Share this Post!

📬 Trilon Newsletter

Stay up to date with all the latest Articles & News!

More from the Trilon Blog .

Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Announcing @nestjs/storage

Meet @nestjs/storage, one API for local disks and S3-compatible storage in NestJS, with safe uploads, signed URLs, direct uploads, and an in-memory disk for tests.

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Distributed Locks with @nestjs/locks

Meet @nestjs/locks, distributed locks for NestJS: run scheduled jobs on one instance, prevent overlapping runs, and elect a leader, with leases and fencing tokens.

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Sending Email with @nestjs/mail

Meet @nestjs/mail, a new package for transactional email in NestJS with typed mail classes, safe templates, SMTP and HTTP transports, and outbox integration.

Read More

What we do at Trilon .

At Trilon, our goal is to help elevate teams - giving them the push they need to truly succeed in today's ever-changing tech world.

Trilon - Consulting

Consulting .

Let us help take your Application to the next level - planning the next big steps, reviewing architecture, and brainstorming with the team to ensure you achieve your most ambitious goals!

Trilon - Development and Team Augmentation

Development .

Trilon can become part of your development process, making sure that you're building enterprise-grade, scalable applications with best-practices in mind, all while getting things done better and faster!

Trilon - Workshops on NestJS, Node, and other modern JavaScript topics

Workshops .

Have a Trilon team member come to YOU! Get your team up to speed with guided workshops on a huge variety of topics. Modern NodeJS (or NestJS) development, JavaScript frameworks, Reactive Programming, or anything in between! We've got you covered.

Trilon - Open-source contributors

Open-source .

We love open-source because we love giving back to the community! We help maintain & contribute to some of the largest open-source projects, and hope to always share our knowledge with the world!

Explore more

Write us a message .

Let's talk about how Trilon can help your next project get to the next level.

Rather send us an email? Write to:

hello@trilon.io
© 2019-2026 Trilon.