Angular recommends standalone components for new code. NgModules remain essential when maintaining module-based applications or integrating libraries that expose module APIs. This lesson covers their compilation scope, provider behavior, feature boundaries, lazy loading, standalone interoperability, and safe migration.
An NgModule defines a compilation scope for non-standalone components, directives, and pipes and can also contribute providers to an injector. NgModules remain supported for existing applications and libraries, but standalone components are the default for new Angular code.
declarations owns non-standalone declarables. imports makes exported declarations from other modules or standalone declarables available to this module. exports publishes selected declarations and imports to consumers.
providers registers dependencies in the module injector. bootstrap names root components only for an application started through bootstrapModule. The old entryComponents field is unnecessary with the Ivy compiler and should not be added to modern modules.
import { Component, NgModule } from '@angular/core';
import { BrowserModule, platformBrowser } from '@angular/platform-browser';
@Component({
selector: 'app-root',
standalone: false,
template: `<h1>Module-based application</h1>`
})
export class AppComponent {}
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule],
bootstrap: [AppComponent]
})
export class AppModule {}
platformBrowser()
.bootstrapModule(AppModule)
.catch(error => console.error(error));
Current Angular declarables are standalone by default, so a component, directive, or pipe placed in declarations must explicitly set standalone: false. BrowserModule belongs only in the root browser NgModule.
A standalone component declares template dependencies in its own imports and an application starts with bootstrapApplication plus ApplicationConfig. This makes ownership visible at the component and application boundaries.
Standalone and NgModule code can interoperate. A standalone component can import an NgModule, and an NgModule can import and export a standalone declarable. Migrate feature by feature instead of rewriting an application at once.
Do not use an NgModule merely as a folder or organization mechanism. Keep one when a library exposes a module API, an existing feature already has a stable module boundary, or provider configuration relies on that integration.
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig)
.catch(err => console.error(err));
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient()
]
};
Provider functions replace many root NgModule imports in standalone applications. Keep deprecated animation providers out of new code; current Angular animations use animate.enter and animate.leave.
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`
})
export class AppComponent { }
NgModule metadata defines a compilation scope for non-standalone declarables and can add providers to an injector. Each field has a different ownership rule; confusing declarations with imports is the most common module error.
| Field | Responsibility |
|---|---|
| declarations | Owns non-standalone components, directives, and pipes. A declarable belongs to exactly one NgModule. |
| imports | Makes exported NgModule declarations and standalone declarables available in this module's templates. |
| exports | Publishes selected declarations or imported dependencies to modules that import this NgModule. |
| providers | Registers dependencies in the module injector; prefer current provide... APIs for new application-wide configuration. |
| bootstrap | Names root components only for an application started through an NgModule. |
| schemas | Changes template validation for specific custom-element integration cases; do not use it to hide missing imports. |
A module name describes an architectural role, not a different Angular type. Root, feature, shared, routing, and core modules all use @NgModule; the difference is what they own, export, configure, and where they are imported.
Keep a feature module aligned with one domain capability. It owns its non-standalone pages, directives, and pipes, imports their direct template dependencies, and exposes only the declarations another feature must use. A shared module should contain reusable presentation pieces, not application state or unrelated dependencies.
| Module role | Owns | Important boundary |
|---|---|---|
| Root AppModule | Root component, BrowserModule, bootstrap and compatibility root providers | Import BrowserModule once and bootstrap only when the app uses bootstrapModule. |
| Feature module | One domain area such as orders, billing or administration | Import direct dependencies; avoid reaching through another feature module. |
| Shared module | Reusable non-standalone UI, directives and pipes | Keep it small and normally provider-free so importing it cannot create hidden state. |
| Routing module | RouterModule.forChild routes for one module-based feature | Exports RouterModule only when declared templates need router directives. |
| Core module convention | Older application-wide infrastructure | Prefer providedIn root or ApplicationConfig in current code; never import a provider-heavy core module repeatedly. |
import { Component, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
@Component({
selector: 'app-orders-page',
standalone: false,
template: `<h2>Orders</h2>`
})
export class OrdersPage {}
const routes: Routes = [
{ path: '', component: OrdersPage }
];
@NgModule({
declarations: [OrdersPage],
imports: [CommonModule, RouterModule.forChild(routes)]
})
export class OrdersModule {}
The feature owns one page and its child route. It imports CommonModule instead of BrowserModule and does not export a route-only page that no other template consumes.
A TypeScript import makes a class name available in a file. The @NgModule imports array changes the Angular compilation or provider scope. You usually need both: import the symbol from its package, then place the NgModule or standalone dependency in metadata.
Import only what declared templates need. Built-in control flow such as @if and @for does not require CommonModule, but compatibility directives and pipes such as NgClass, AsyncPipe, DatePipe, and JsonPipe do.
| Dependency | Where it belongs | What it supplies |
|---|---|---|
| BrowserModule | Root browser NgModule only | Browser platform services and a re-export of CommonModule. |
| CommonModule | Feature and shared NgModules as needed | Common directives and pipes without reinstalling browser platform providers. |
| FormsModule | Module whose templates use template-driven forms | ngModel and template-driven form directives. |
| ReactiveFormsModule | Module whose templates use reactive forms | formGroup, formControl and reactive forms directives. |
| RouterModule.forRoot(routes) | Root module once | Router services plus the root route configuration. |
| RouterModule.forChild(routes) | Feature routing module | Additional routes without reinstalling the Router service. |
NgModule imports combine provider records as Angular builds an injector. Providers from eagerly imported modules normally join the application injector. A lazy-loaded NgModule receives a child environment injector, so a token provided there can resolve to a feature-specific instance.
The forRoot convention separates one-time application configuration from reusable declarations. Import Module.forRoot(config) once at the root and import the plain module, or use its forChild API, elsewhere. For new libraries, a typed provideFeature(config) function is usually clearer and works naturally with standalone bootstrapping.
import {
InjectionToken,
ModuleWithProviders,
NgModule
} from '@angular/core';
export const API_BASE_URL =
new InjectionToken<string>('API_BASE_URL');
@NgModule()
export class ApiClientModule {
static forRoot(baseUrl: string):
ModuleWithProviders<ApiClientModule> {
return {
ngModule: ApiClientModule,
providers: [
{ provide: API_BASE_URL, useValue: baseUrl }
]
};
}
}
@NgModule({
imports: [ApiClientModule.forRoot('/api')]
})
export class AppModule {}
ModuleWithProviders keeps the returned NgModule type explicit. Configure the provider once at the root; feature modules that need declarations from the package import its non-root API.
A module is not lazy merely because it is named FeatureModule. The router creates a separate chunk only when loadChildren reaches the module through a dynamic import and no eager import pulls that feature into the initial graph.
The lazy module configures its internal routes with RouterModule.forChild. Do not also import that feature module into AppModule. The router creates the module and its child injector when navigation first matches the lazy route.
import { Routes } from '@angular/router';
export const appRoutes: Routes = [
{
path: 'orders',
loadChildren: () =>
import('./orders/orders.module')
.then(module => module.OrdersModule)
}
];
// OrdersModule owns an empty-path child route:
// RouterModule.forChild([
// { path: '', component: OrdersPage }
// ])
The dynamic import creates the lazy boundary. OrdersModule must not also appear in an eager imports array, or the intended bundle split is lost.
Read the first Angular compiler or injector diagnostic and classify it as ownership, compilation scope, export surface, provider scope, or routing. This is faster than adding modules to SharedModule until the error disappears.
| Symptom | Cause | Smallest correction |
|---|---|---|
| Standalone declarable appears in declarations | Current declarables default to standalone. | Put it in imports, or deliberately mark compatibility code standalone: false. |
| Declared by more than one NgModule | Two modules claim the same non-standalone declarable. | Choose one owner, export from that owner, and import the owner elsewhere. |
| Unknown element, property or pipe | The declaring module cannot see the dependency. | Import the standalone dependency or the NgModule that exports it into the correct module. |
| Component is not visible to a consumer | The owner declared it but did not export it. | Export that public declarable from its owner; do not redeclare it. |
| BrowserModule already loaded | A feature or lazy module imported BrowserModule again. | Keep BrowserModule in the root and use CommonModule in features. |
| Unexpected service instance | A provider was repeated or placed in a lazy child injector. | Inspect the provider owner and injector hierarchy; move the provider to the intended root, route, or component boundary. |
| Lazy feature is in the initial bundle | The feature module is also imported eagerly. | Remove the eager import and retain only the loadChildren dynamic import. |
Migrate ownership before deleting a module. A component becomes standalone, imports its template dependencies directly, and moves application-wide provider configuration to bootstrap or route providers.
Angular provides an interactive standalone migration schematic. Run its phases in order, inspect every change, and commit between phases: convert declarations, switch bootstrap, then remove unnecessary NgModules. Manual cleanup is still required for application-specific providers and module APIs.
| NgModule responsibility | Standalone destination |
|---|---|
| declarations | The component, directive, or pipe becomes standalone and owns its imports. |
| template imports | The consuming standalone component imports each dependency directly. |
| root providers | ApplicationConfig or providers passed to bootstrapApplication. |
| feature providers | Route providers or the component provider boundary that should own the lifetime. |
| lazy feature module | A lazy route using loadComponent or loadChildren with route arrays. |
# Run once for each interactive migration phase
ng generate @angular/core:standalone
# Verify after every phase before continuing
ng test
ng build
Use a clean branch and review the schematic diff after each run. The migration can preserve NgModules that still bootstrap components or contain code it cannot safely remove.
A non-standalone component gets one owner. Declare it in the feature or shared module responsible for it, export it there, and import that module elsewhere. If the component is standalone, it belongs in imports, not declarations.
Move application-wide providers into ApplicationConfig or the providers passed to bootstrapApplication. Router configuration becomes provideRouter(routes), HTTP configuration uses provideHttpClient(), and other libraries usually expose a provide... function or an importProvidersFrom() bridge. Standalone and NgModule code interoperate, so migrate one route or feature at a time instead of treating the change as all-or-nothing.
BrowserModule and application-wide provider configuration belong at the root. A feature or lazy NgModule imports CommonModule plus only the forms, router, or UI dependencies its declarations use. In module APIs, forRoot() conventionally installs root providers once, while forChild() contributes feature configuration without reinstalling them; modern libraries may expose provide... functions instead.
Explore 500+ free tutorials across 20+ languages and frameworks.