On This Page
1. The Error
You inject HttpClient (or any other service) and the app throws instead of rendering:
ERROR NullInjectorError: R3InjectorError(Standalone[_AppComponent])[_HttpClient -> _HttpClient -> _HttpClient]:
NullInjectorError: No provider for _HttpClient!
at NullInjector.get (core.mjs:1234:27)
at R3Injector.get (core.mjs:2456:33)
at R3Injector.get (core.mjs:2456:33)
at ChainedInjector.get (core.mjs:9876:36)
at injectInjectorOnly (core.mjs:745:12)
at Module.ɵɵinject (core.mjs:759:59)
at Object.UserService_Factory [as factory] (user.service.ts:9:39)
at R3Injector.hydrate (core.mjs:2589:29)
at R3Injector.get (core.mjs:2438:33)
at ChainedInjector.get (core.mjs:9876:36)Angular's dev-mode console appends a link to the canonical explanation: Find more at https://angular.dev/errors/NG0201. The leading underscores on _HttpClient are Angular's minifier-safe token naming in newer builds — don't let them make you think the class name changed.
The same failure shows up with any missing provider, not just HttpClient. The generic shape is:
NullInjectorError: R3InjectorError(<InjectorName>)[<Token> -> <Token> -> <Token>]:
NullInjectorError: No provider for <Token>!<InjectorName> tells you which injector gave up — Standalone[AppComponent], R3Injector, or a lazy route's EnvironmentInjector name. The repeated <Token> -> <Token> -> <Token> chain is the walk up the injector hierarchy, not three different failures — read it right to left as "starting from the root, still didn't find it."
In a production build the message is minified and terser: you get NullInjectorError: No provider for X! with no readable stack, or in some optimization configurations nothing but a blank screen and a caught, silently-swallowed rejection if you have a permissive global ErrorHandler. Dev-mode's descriptive chain is a ngDevMode-gated assertion; it does not ship to production, which is why this bug is far easier to diagnose locally than after a deploy.
This applies unchanged from Angular 14 (when the NG0201-coded Ivy error pages were introduced) through the current v22 line. What does change across versions is how you're expected to register the provider: NgModule providers: [HttpClientModule] (pre-v15), then provideHttpClient() for standalone apps (v15+), consistently through v22. Angular has not made HttpClient injectable without a provider in any shipped version — if you find a blog post claiming provideHttpClient() is now optional, verify it against angular.dev/guide/http/setup for the version you're on before trusting it.
2. How to Reproduce It (step-by-step)
Scaffold a plain standalone app and inject HttpClient without registering a provider for it.
npx @angular/cli@22 new repro-ng0201 --standalone --routing=false --style=css --skip-git
cd repro-ng0201package.json dependency block (pinned, matches what ng new on the v22 line generates):
{
"dependencies": {
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0"
},
"devDependencies": {
"@angular/build": "^22.1.0",
"@angular/cli": "^22.1.0",
"typescript": "~6.0.0"
}
}Node 22.22.3+ (or 24.15.0+ / 26.x) to match the v22 compatibility matrix.
src/app/user.service.ts:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers() {
return this.http.get('/api/users');
}
}src/app/app.component.ts:
import { Component, inject } from '@angular/core';
import { UserService } from './user.service';
@Component({
selector: 'app-root',
standalone: true,
template: `<pre>{{ users | json }}</pre>`,
imports: [],
})
export class AppComponent {
private users$ = inject(UserService).getUsers();
users = null;
}src/app/app.config.ts — the bug is what's missing here:
import { ApplicationConfig } from '@angular/core';
import { provideBrowserGlobalErrorListeners } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
// provideHttpClient() was never added
],
};npm install
npm startOpen the app: the console throws NullInjectorError: No provider for _HttpClient! the instant UserService is constructed, because AppComponent's field initializer calls getUsers() eagerly. Run ng build --configuration production and load the built output — you get the same failure, just minified.
Where this bites differently:
- Standalone apps (v15+): forgetting
provideHttpClient()inapp.config.ts— the single most common trigger today. - Lazy-loaded routes with their own providers: a feature route supplies its own
EnvironmentInjectorviaproviderson the route config; ifprovideHttpClient()was only added to the rootapp.config.tsbut the lazy route is configured withproviders: [SomeOtherProvider]that accidentally replaces rather than extends the chain (rare, but happens with certain testing harnesses and micro-frontend setups), the lazy injector can't see it. - Unit tests:
TestBed.configureTestingModulefor a component or service that injectsHttpClientwithoutprovideHttpClient()/provideHttpClientTesting()in itsprovidersarray — this is the single most common way teams hitNG0201after their app already works, because the app'sapp.config.tsnever runs in a spec. - Storybook, Cypress component testing, Module Federation, or any harness that bootstraps a component outside your normal
app.config.ts— same root cause: a different bootstrap path means the providers you registered for the real app never ran. - Karma/Jasmine → Vitest migration on v21+: specs that used to inherit
HttpClientTestingModulefrom a shared NgModule-based test setup and now run standalone need the provider added explicitly per spec or per shared test harness.
3. Version Behaviour Matrix (Angular v17 / v18 / v19 / v20 / v21 / v22)
| Version | Provider registration | Error code / wording | Notes |
|---|---|---|---|
| v17 (Nov 2023) | provideHttpClient() in app.config.ts (standalone is the ng new default); HttpClientModule still works in NgModule apps | NG0201, same R3InjectorError chain | No change to the DI mechanics from earlier Ivy versions |
| v18 (May 2024) | Same | Same | @angular/build package introduced; doesn't affect this error |
| v19 (Nov 2024) | Same | Same | standalone: true becomes the implicit default for new components — NgModule-declared components must now opt out with standalone: false; increases the share of apps hitting the standalone-specific provideHttpClient() version of this mistake |
| v20 (May 2025) | Same | Same | Zoneless goes stable in 20.2; unrelated to DI, but zoneless specs surface NG0201 faster since there's no zone-driven retry masking a race |
| v21 (Nov 2025) | Same | Same | Vitest becomes the default test runner for new projects — teams porting Karma specs to Vitest are the most common source of new NG0201 reports this cycle, because shared TestBed setup helpers don't always port cleanly |
| v22 (May 2026, 22.1.x current) | Same, plus @Service() as an alternative to @Injectable() for simple root-singleton services (still requires provideHttpClient() at the app level — @Service() changes how the class opts into DI, not whether the app registers HttpClient itself) | Same | OnPush becomes the default change-detection strategy for new components; unrelated to this error but often debugged in the same session when a service throws before first render |
provideHttpClient() and the NG0201 wording have been stable since Angular 15 (when provideHttpClient() was introduced) and Angular 14 (when the coded NGxxxx error pages launched), respectively. This is a version-neutral error in the sense that the fix mechanics haven't moved in the standalone era — the version axis that actually matters is standalone vs NgModule and app bootstrap vs test bootstrap, not the Angular major.
Ecosystem layer: provideHttpClientTesting() (from @angular/common/http/testing) has kept the same signature since its introduction alongside provideHttpClient(); order matters — register provideHttpClient(...) before provideHttpClientTesting() in a TestBed providers array, since the testing provider intentionally overrides the real backend piece of the HTTP provider set.
4. Why It Happens — Surface Level
HttpClient (like every other injectable) isn't magically available just because you imported the class — Angular's DI system only knows how to construct something if a provider for it was registered somewhere in the injector chain that's active when the injection happens. provideHttpClient() is that registration for HttpClient specifically. If it's missing from app.config.ts (or providers: [HttpClientModule] in the older NgModule world, or provideHttpClient() in a TestBed providers array for a spec), the injector walks all the way to the root, finds nothing, and throws NullInjectorError.
5. Why It Happens — Under the Hood
Angular's DI is a hierarchy of injectors, not a single global registry. There's the root EnvironmentInjector (built from app.config.ts's providers array, plus anything a lazy route or component adds), and ElementInjectors attached to each component's view for component-level providers/viewProviders. When you call inject(HttpClient) — or Angular calls it for you via a constructor parameter — ɵɵinject walks from the current injection context outward: element injector, then its ancestors, then the module/environment injector, then its parent environment injectors (this is what the [HttpClient -> HttpClient -> HttpClient] chain in the error is actually showing: the same token requested at each level of the walk), and finally the special NullInjector at the very top. The NullInjector's get() implementation does exactly one thing: throw. That thrown error is what you see, and the injector name embedded in R3InjectorError(Standalone[AppComponent]) tells you which injector's hydrate() call triggered the walk that failed — in a standalone app it's usually the injector created for the root component by bootstrapApplication.
provideHttpClient() isn't just { provide: HttpClient, useClass: HttpClient }. It's a provider set — it registers HttpClient itself, the HttpBackend token bound to either FetchBackend or HttpXhrBackend, the HTTP_INTERCEPTOR_FNS multi-provider array for functional interceptors, and (if opted in) HTTP_INTERCEPTORS for legacy class-based ones via withInterceptorsFromDi(). Omit the call and none of that exists in the injector — which is why the error names HttpClient specifically even though the gap is the whole provider set.
This is also why the failing stack frame is often several calls deep. A service marked @Injectable({ providedIn: 'root' }) is tree-shakable — Angular only instantiates it, and only then tries to resolve its dependencies, the first time something actually injects it. AppComponent injects UserService, UserService's factory tries to inject HttpClient, and HttpClient's provider is what's actually missing — the NullInjectorError throws from inside UserService_Factory, not from AppComponent's own construction.
Evidence worth pulling when triaging this in a real app:
npx ng version # confirm CLI/core/TS alignment
grep -R "provideHttpClient" src/app/app.config.ts
grep -RL "provideHttpClient\|provideHttpClientTesting" src/**/*.spec.tsThe second grep finds every spec file that injects something requiring HttpClient transitively but never registers it — usually the fastest way to find every failing test in one pass after a Karma-to-Vitest port.
6. The Fix
App code — standalone (v15+, current recommended form):
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
+ import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideBrowserGlobalErrorListeners } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
+ provideHttpClient(withInterceptors([authInterceptor])),
],
};Legacy NgModule apps (only if you're still pre-migration — label this as legacy):
// app.module.ts
import { NgModule } from '@angular/core';
- import { HttpClientModule } from '@angular/common/http';
+ import { provideHttpClient } from '@angular/common/http';
@NgModule({
providers: [
+ provideHttpClient(),
],
})
export class AppModule {}HttpClientModule still resolves on the v22 line but is a deprecated compatibility shim; prefer provideHttpClient() in the module's providers array even without migrating the rest of the module.
Unit tests (Vitest or Karma, TestBed):
// user.service.spec.ts
import { TestBed } from '@angular/core/testing';
+ import { provideHttpClient } from '@angular/common/http';
+ import { provideHttpClientTesting } from '@angular/common/http/testing';
import { UserService } from './user.service';
describe('UserService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
],
});
});
});Order matters: provideHttpClient() first, provideHttpClientTesting() second — the testing provider deliberately overrides the real network-issuing backend, and registering it first means provideHttpClient()'s defaults win instead.
Lazy route with its own providers:
// app.routes.ts
{
path: 'admin',
loadComponent: () => import('./admin/admin.component'),
- providers: [AdminOnlyService],
+ providers: [AdminOnlyService], // fine — this EXTENDS the parent injector, it doesn't replace it
}Route-level providers add to the chain; they don't cut it off. If HttpClient is missing here too, the real gap is still upstream in app.config.ts — check there first before assuming route providers are the problem.
Real fix vs. papering over it:
- ✅
provideHttpClient()inapp.config.ts— the actual fix for the app. - ✅
provideHttpClient()+provideHttpClientTesting()per spec, or in a sharedTestBedhelper — the actual fix for tests. - ⚠️
inject(HttpClient, { optional: true })with null-checks everywhere it's used — silences the crash but leaves every HTTP call quietly no-op-ing instead of failing loudly where the misconfiguration lives. Fine for a genuinely optional collaborator, not as a substitute for the missing provider. - ❌ Importing
HttpClientModuleinto every standalone component'simportsarray instead of fixingapp.config.ts— multiplies the places you have to remember it. - ❌ Catching the error in a global
ErrorHandlerand swallowing it — turns a one-line fix into a silent production incident.
Clean-rebuild recipe, useful when the provider is registered correctly but the error persists (usually a duplicate-package or stale-cache symptom, not a DI-logic bug):
rm -rf node_modules package-lock.json
npm cache verify
npm install
rm -rf .angular/cache # CLI's build cache, keyed by config + deps
npx ng cache clean
npm ls @angular/core # prove there is exactly one copy
npx ng version # prove CLI, framework, TS and Node line up
npx ng build --configuration productionnpm ls @angular/core matters here specifically: two copies of @angular/core in node_modules (common in monorepos with a misbehaving hoisting setup) means two separate NullInjector classes exist, and a provider registered against one copy's injector tree is invisible to code running against the other — producing an NG0201 that looks identical to a simple missing-provider bug but isn't fixed by adding the provider again.
7. Best Practices & The Better Design
Register cross-cutting providers exactly once, at the root, using provider functions — not scattered module imports, and not re-declared per-component:
// app.config.ts — the single source of truth for app-wide providers
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
};For tests, don't repeat provideHttpClient() + provideHttpClientTesting() in every spec file — put them in one shared test setup used across the suite:
// test-setup.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
export function configureHttpTestBed() {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
}Prefer inject(HttpClient, { optional: true }) only for genuinely optional collaborators, never as a substitute for registering the real provider. And when a service's dependency graph is simple — no useClass/useFactory overrides needed, no non-root scope — @Service() (Angular 22+) removes the providedIn: 'root' boilerplate entirely; keep @Injectable() for anything that needs constructor injection or advanced provider keys.
8. How to Prevent It Long-Term
Run ng build --configuration production in CI, not just ng serve locally — dev-mode's descriptive text and production's minified failure diverge, and CI is where you want to catch the loud version. Add npm ls @angular/core (or pnpm why @angular/core) as a CI check in any monorepo, so a duplicate-copy regression fails the build instead of surfacing as an intermittent NG0201 weeks later. Keep one shared TestBed helper for HTTP-dependent specs so a Karma-to-Vitest port only needs one file updated, not every spec. Treat ng update's automated migrations as a checklist item on every upgrade ticket — angular.dev/update-guide flags provider-registration changes per version. Finally, wire a global ErrorHandler plus source-map upload (Sentry/Rollbar) so a leak into production produces a symbolicated stack instead of a silent blank page.
Related concepts worth linking: DI-context failures (NG0203: inject() must be called from an injection context) are a different bug in the same subsystem — NG0201 is about a missing provider, NG0203 is about calling inject() from the wrong place with a provider that does exist. Circular DI (NG0200) is a third, distinct failure in the same neighborhood.
9. Key Takeaways / Learnings
NullInjectorError: No provider for X!(NG0201) means the injector hierarchy was walked to the root and no provider for that token was ever registered — it is not a typo or import-path bug in most cases.- For
HttpClientspecifically, the fix isprovideHttpClient()inapp.config.ts(or a module'sproviders), andprovideHttpClient()+provideHttpClientTesting()(in that order) in every spec that needs it. - Dev mode shows the full
R3InjectorErrorchain and injector name; production minifies it — always verify aNullInjectorErrorfix against a production build, not justng serve. - Route-level and lazy-loaded providers extend the injector chain, they don't replace it — if
HttpClientis missing in a lazy route, the gap is almost always upstream at the root. - Two copies of
@angular/coreinnode_modulesproduce this exact error even with a correctly registered provider —npm ls @angular/coreis the first thing to check when the "obvious" fix doesn't work.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.