Angular NgModules (Legacy), Module Routing, Lazy Modules and Migration to Standalone

Angular Tutorial: Beginner to Advanced · 15. Architecture, configuration and errors · 4 min read · Updated

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, exports and providers mean
  • 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:

  1. ng generate @angular/core:standalone-migration and choose Convert all components, directives and pipes to standalone. It adds imports to each component and removes standalone: false.
  2. Run it again and choose Remove unnecessary NgModule classes.
  3. Run it again and choose Bootstrap the application using standalone APIs. It replaces platformBrowserDynamic().bootstrapModule(AppModule) with bootstrapApplication(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.forRoot in a lazy module. Use forChild.
  • Starting a new project with modules. Use standalone.

Try it yourself

  1. Create a small module with two components and test it as above.
  2. Run the migration with --dry-run and read what it would change.
  3. Move one feature of your app into features/<name> with its own routes.ts.

Quick check

  1. Where does a module based template get the things it can use?
  2. What is the difference between forRoot and forChild?
  3. Name the three migration steps.

Next, continue with Angular Custom Form Controls with ControlValueAccessor.

Frequently asked questions

Are NgModules removed from Angular?

No. They still work and old libraries use them. But new code should use standalone components, and you can mix both in one app.

Do I need modules for lazy loading?

No. Lazy load standalone components with loadComponent or a route array with loadChildren.