Before Angular 14 every component had to belong to an NgModule. New apps no longer need them: components are *standalone*. But millions of lines of Angular code still use modules, so you must be able to read them and move them forward.
What you will learn
- What an NgModule is and what
declarations,imports,exportsandprovidersmean - Routing and lazy loading with modules
- A good folder structure for either style
- How to migrate to standalone with the official schematic
The idea behind a module
An NgModule is a box. Inside the box are the components, directives and pipes that belong together. The box also lists what it needs from other boxes.
// src/app/legacymod/badge.ts
import { Component, Input, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-badge',
standalone: false, // this component belongs to a module
template: `<span class="badge">{{ label }}</span>`,
})
export class Badge {
@Input() label = '';
}
@Component({
selector: 'app-badge-list',
standalone: false,
template: `
@for (item of items; track item) {
<app-badge [label]="item" />
}
`,
})
export class BadgeList {
items = ['new', 'sale', 'popular'];
}
@NgModule({
declarations: [Badge, BadgeList], // components that belong to this module
imports: [CommonModule], // other modules whose features we use
exports: [BadgeList], // what other modules may use
providers: [], // services (rarely needed: use providedIn: 'root')
})
export class BadgeModule {}
The four lists:
- declarations: components, directives and pipes owned by this module. A thing can be declared in only one module.
- imports: other modules this one needs (
FormsModule,CommonModule,RouterModule). - exports: things other modules may use when they import this one.
- providers: services scoped to the module.
Why this was painful: a template could only use what its module imported, not what the component itself imported. Standalone components moved that list to the component (imports: [...]), which is easier to follow.
Routing with modules
A feature module has its own routes, registered with RouterModule.forChild. The root uses forRoot:
// src/app/legacymod/shop.module.ts
import { Component, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@Component({ selector: 'app-shop-home', standalone: false, template: '<h2>Shop home</h2>' })
export class ShopHome {}
const routes: Routes = [{ path: '', component: ShopHome }];
@NgModule({
declarations: [ShopHome],
imports: [RouterModule.forChild(routes)], // child routes for a feature
})
export class ShopModule {}
Lazy load the whole module from the root routes with loadChildren:
// File: legacy lazy loaded module
{
path: 'shop',
loadChildren: () => import('./legacymod/shop.module').then((m) => m.ShopModule),
}
With standalone you do the same without a module, using a route array:
// File: the modern equivalent
{
path: 'shop',
loadChildren: () => import('./shop/shop.routes').then((m) => m.SHOP_ROUTES),
}
Folder structure that scales
Group by feature, not by type. Everything for one feature lives together:
src/app/
core/ services used app wide: auth, http interceptors, config
shared/ small reusable pieces: buttons, pipes, directives
features/
products/
products.routes.ts
product-list.ts
product-detail.ts
products.service.ts
orders/
orders.routes.ts
...
app.ts
app.config.ts
app.routes.ts
Rule of thumb: a feature may import from core and shared, but one feature should not import from another feature.
Test a module based component
// src/app/legacymod/badge.spec.ts
import { TestBed } from '@angular/core/testing';
import { BadgeList, BadgeModule } from './badge';
describe('BadgeModule', () => {
it('renders the declared components', async () => {
await TestBed.configureTestingModule({ imports: [BadgeModule] }).compileComponents();
const fixture = TestBed.createComponent(BadgeList);
await fixture.whenStable();
const badges = (fixture.nativeElement as HTMLElement).querySelectorAll('.badge');
expect(badges.length).toBe(3);
});
});
Migrate to standalone
Angular ships a schematic that does the work in three safe steps. Commit after each one and run your tests:
ng generate @angular/core:standalone-migrationand choose Convert all components, directives and pipes to standalone. It addsimportsto each component and removesstandalone: false.- Run it again and choose Remove unnecessary NgModule classes.
- Run it again and choose Bootstrap the application using standalone APIs. It replaces
platformBrowserDynamic().bootstrapModule(AppModule)withbootstrapApplication(App, appConfig).
You can also pass a mode directly, for example --mode=prune-ng-modules, and use --dry-run to preview. After the migration, replace *ngIf and *ngFor with the built-in control flow using ng generate @angular/core:control-flow-migration.
Do it one folder at a time in a large app, using --path src/app/feature.
Common mistakes
- Declaring the same component in two modules.
- Forgetting to add a module to
imports, then seeing "is not a known element". - Using
RouterModule.forRootin a lazy module. UseforChild. - Starting a new project with modules. Use standalone.
Try it yourself
- Create a small module with two components and test it as above.
- Run the migration with
--dry-runand read what it would change. - Move one feature of your app into
features/<name>with its ownroutes.ts.
Quick check
- Where does a module based template get the things it can use?
- What is the difference between
forRootandforChild? - Name the three migration steps.
Next, continue with Angular Custom Form Controls with ControlValueAccessor.