Ch. 4 · Angular

Angular Standalone Components and Template Imports

How a standalone component's imports array works, the NG8001, NG8002 and NG8004 errors it prevents, and the directive that fails silently.

~5 min readbeginnerupdated Oct 7, 2026

A standalone component carries its own list of template dependencies. Instead of an NgModule somewhere declaring “these components can see each other”, each component says, in its imports array, exactly which components, directives and pipes its template uses. That makes components easier to move, lazy-load and test, because everything a template needs is listed next to it.

It also means the compiler checks every template against that list. Most mistakes become clear errors, and one doesn’t. This note walks through all of them with a small app built with the Angular 20.3 CLI. The error text below is copied from ng build. Angular 19 is the same, since that’s the release where standalone became the default.

What “standalone” means in Angular 19 and 20

Before Angular 19 you wrote standalone: true on every component that didn’t belong to a module. In 19 the default flipped: a @Component, @Directive or @Pipe is standalone unless it says standalone: false. New CLI projects don’t generate NgModules at all, and the app starts with bootstrapApplication(App, appConfig) instead of platformBrowserDynamic().bootstrapModule(AppModule).

So a minimal component looks like this, with no standalone flag at all:

import { Component, input } from '@angular/core';

@Component({
  selector: 'app-badge',
  template: `<span>{{ count() }}</span>`,
})
export class Badge {
  count = input(0);
}
TypeScript

The component is self-contained, but its template can only use things it imports. Badge uses nothing except interpolation, so it needs no imports.

Forgetting to import a component

Now a root component that uses Badge and the date pipe, without importing either:

@Component({
  selector: 'app-root',
  template: `<app-badge [count]="3" /> <p>{{ today | date: 'short' }}</p>`,
})
export class App {
  today = new Date();
}
TypeScript

ng build fails with three errors. The first two:

✘ [ERROR] NG8001: 'app-badge' is not a known element:
1. If 'app-badge' is an Angular component, then verify that it is included in the '@Component.imports' of this component.
2. If 'app-badge' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@Component.schemas' of this component to suppress this message.

✘ [ERROR] NG8002: Can't bind to 'count' since it isn't a known property of 'app-badge'.
Text

NG8002 is a consequence of NG8001. The compiler doesn’t know app-badge is a component, so it treats it as an unknown HTML element, and unknown elements don’t have a count property. Fix the import and both go away.

Read the second suggestion in NG8001 carefully. CUSTOM_ELEMENTS_SCHEMA is for real Web Components (a <model-viewer> tag from a script, say). Adding it to silence an error about your own Angular component makes the error disappear and the component never render, because Angular now treats <app-badge> as an opaque custom element. If you see that schema in a code review next to an Angular selector, it’s almost always hiding a missing import. The is-not-a-known-element guide covers the remaining causes.

Pipes and the common directives

The third error is about the pipe:

✘ [ERROR] NG8004: No pipe found with name 'date'.
To fix this, import the "DatePipe" class from "@angular/common" and add it to the "imports" array of the component.
Text

Pipes from @angular/common aren’t global in standalone components. You import DatePipe, CurrencyPipe, AsyncPipe and friends individually. You can import the whole CommonModule instead, and it still works, but listing what you use keeps the dependency list honest and lets unused pipes be tree-shaken.

The built-in control flow (@if, @for, @switch) is part of the template syntax, not a directive, so it needs no import. NgIf, NgFor and NgSwitch still exist for older code; if a template uses *ngIf, it needs NgIf (or CommonModule) in imports.

The fixed component:

import { DatePipe } from '@angular/common';
import { Badge } from './badge';

@Component({
  selector: 'app-root',
  imports: [Badge, DatePipe],
  template: `<app-badge [count]="3" /> <p>{{ today | date: 'short' }}</p>`,
})
export class App { today = new Date(); }
TypeScript

The directive that fails silently

Here’s the case that interviewers like, because it doesn’t produce an error. An attribute directive that focuses its host element:

@Directive({ selector: '[appAutofocus]' })
export class Autofocus {
  constructor() { inject(ElementRef).nativeElement.focus(); }
}
TypeScript

Use it without importing it, <input appAutofocus />, and the build succeeds: ng build on Angular 20.3 reports no error and no warning. The input simply never gets focus.

The reason is that appAutofocus is a perfectly valid HTML attribute name. The compiler has no way to know you meant a directive, because unknown attributes are allowed on any element. Unknown elements and unknown property bindings are checked; plain attributes aren’t.

Many directives use their selector as an input name too. Written as a bare attribute they stay silent, but once you write a property binding, the compiler checks it. For example, <input [formControl]="name"> without the forms module gives:

✘ [ERROR] NG8002: Can't bind to 'formControl' since it isn't a known property of 'input'.
Text

So the rule of thumb is: bindings are checked, bare attributes are not. When a directive “does nothing”, check the host component’s imports first.

Importing NgModules and legacy components

imports accepts NgModules too. ReactiveFormsModule is still a module in Angular 20, and adding it to a standalone component’s imports brings in FormControlDirective, FormGroupDirective and the rest. After adding it to the example above, the build completed (136 kB main bundle).

What you can’t do is import a component that’s declared in a module:

✘ [ERROR] NG2011: The component 'Legacy' appears in 'imports', but is not standalone
and cannot be imported directly. It must be imported via an NgModule.
Text

Either import the NgModule that declares and exports Legacy, or migrate Legacy to standalone. The CLI’s ng generate @angular/core:standalone schematic does the mechanical part of that migration in stages.

imports versus providers

A frequent mix-up: putting a service in imports. imports is only for things a template uses. Services are provided with @Injectable({ providedIn: 'root' }), a component’s providers array, or the providers in appConfig. Library setup that used to be HttpClientModule or RouterModule.forRoot(routes) is now a provider function in appConfig: provideHttpClient(), provideRouter(routes). If an old library only ships a module with providers, importProvidersFrom(SomeModule) in appConfig bridges it. Getting this wrong usually ends in a NullInjectorError, not a template error.

Interview practice

A developer moves a ProductCard component from a shared NgModule to standalone. Afterwards the product list renders the cards, but prices show as raw numbers like 1299.5 instead of $1,299.50, and a [appHighlight] hover directive used inside the card no longer highlights. There are no build errors. What’s going on?

Answer

The card template uses the currency pipe and the appHighlight directive, and both used to be visible through the shared module. As a standalone component, the card only sees what its own imports lists.

The highlight is the silent case: appHighlight written as a bare attribute is valid HTML, so the compiler can’t tell it’s meant to be a directive. Without Highlight in imports, Angular never instantiates it. If the template had a binding like [appHighlight]="color", the build would have failed with NG8002, which is a useful clue for the interviewer.

The pipe is more interesting, because a missing pipe is a compile error (NG8004). If the build was clean, the template probably doesn’t use the pipe at all anymore: a common migration slip is that someone “fixed” the error by removing | currency or replacing it with a method, which is why raw numbers appear. I’d check the diff for that.

The fix is imports: [CurrencyPipe, Highlight] on ProductCard and restoring the pipe. To catch the silent case in future I’d add a component test that asserts the directive’s effect (the highlight class after a mouseenter), since the compiler can’t.

Further reading

More in Angular

read ✓Angular · hard

Angular Component Tests That Verify Behavior

Test an Angular component through its DOM, inputs, outputs and HTTP calls, so refactors pass and real bugs fail. Five tests, run and broken.

~6 min readread →
read ✓Angular · mid

Angular Defer Blocks and Lazy Rendering

How @defer splits a template region into its own chunk, which trigger to pick, and why SSR renders only the placeholder unless you opt in.

~7 min readread →
esc