The schematic is good. It is also allowed to give up, and it does not make a fuss about it.
If you ran the standalone migration on an established codebase and it told you there was nothing to be done, it was not reporting success. The schematic can only remove an NgModule when nothing depends on that module in a way the tool is unable to rewrite, and on a codebase of any age there are usually four or five things doing exactly that.
None of them are hard. They are just invisible until you know what you are looking at, and the tool does not tell you. This post is the list.
1. What the schematic actually does
The migration itself is genuinely good and worth running:
ng generate @angular/core:standalone-migration
ng generate @angular/core:common-to-standalone-migration
ng generate @angular/core:cleanup-unused-importsIt converts declarations to standalone, moves what those components need into their own imports arrays, and removes modules that have become empty. On the parts of a codebase that are just components and templates, it does nearly all the work.
Worth noting while you are here: you no longer write standalone: true. Standalone is the default. The option still exists so you can set it to false when you deliberately want a component available to an NgModule, which is the opposite of how it started. Older tutorials showing standalone: true everywhere are describing a version of Angular that has moved on.
2. “Nothing to be done” is not success
This is the message that sends people to a search engine, so let me answer it directly.
The schematic removes a module only when it can prove the module is no longer needed. If anything about that module cannot be safely rewritten, the tool leaves the module alone rather than producing something broken. That is correct behaviour, but the reporting makes it look like completion rather than refusal.
There are five things in a module that determine whether it can go. Two of them the schematic handles well, and three of them are your job:
- Declarations. Handled. Not your problem.
- Providers. Yours.
- Bootstrap components. Handled, and only relevant to your root module anyway.
- Imports of modules using
forRoot()orforChild(). Yours, and this is the big one. - Class members on the module itself. Yours, and the easiest to overlook entirely.
Work through 2, 4 and 5 and the schematic will start deleting modules on the next run. Until you do, it will keep politely telling you there is nothing to be done.
3. Providers
A module with a providers array cannot be deleted, because deleting it would remove the provider. So each one has to go somewhere else first, and there are two destinations.
Occasionally you want a service scoped to a single component and its children, and then the provider belongs on the component:
@Component({
selector: 'app-report-panel',
templateUrl: './report-panel.component.html',
providers: [ReportStateService],
})
export class ReportPanelComponent {
private readonly reportState = inject(ReportStateService);
}Most of the time, though, the service was effectively application-wide and the module was just where it happened to be registered. In that case the better destination is the service itself:
@Injectable({ providedIn: 'root' })
export class ReportStateService {
// ...
}This is the option I would reach for first, and not only because it deletes a line from a module you are trying to remove. A service registered this way is tree-shakable: the compiler can tell which services are genuinely referenced and drop the ones that are not. A service in a module providers array is retained because the module said so, whether or not anything uses it. On an old codebase there are usually more of those than anyone expects.
4. forRoot and ModuleWithProviders
This is where large codebases actually get stuck, and it was comfortably the biggest blocker for us.
When a module imports another module through forRoot() or forChild(), it is not importing a class, it is importing a ModuleWithProviders object built at runtime. The schematic cannot rewrite that into a standalone imports array, because the providers it carries only exist as the result of calling the function. So any module containing such an import survives, and so does every module importing that one.
The result is that a handful of shared modules can pin a very large number of other modules in place. If your codebase has a SharedModule used almost everywhere, and that module imports a few third-party modules via forRoot(), then almost nothing can be converted until it is dealt with.
Third-party packages are the most common source, but the ones that surprised us were our own. A hand-rolled forRoot looks like this:
@NgModule({ /* ... */ })
export class SharedMapsModule {
static forRoot(config?: MapsConfig): ModuleWithProviders<SharedMapsModule> {
return {
ngModule: SharedMapsModule,
providers: [
{ provide: MapsLoaderService, useClass: LazyMapsLoaderService },
{ provide: MAPS_CONFIG, useValue: config },
],
};
}
}The fix is to stop expressing configuration as a module and start expressing it as providers, which is what standalone applications do anyway:
export function provideMaps(config?: MapsConfig): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: MapsLoaderService, useClass: LazyMapsLoaderService },
{ provide: MAPS_CONFIG, useValue: config ?? {} },
]);
}Then callers use provideMaps() in their application config instead of importing a module. For third-party packages, check whether the library already ships an equivalent, because most maintained ones now do and it is usually named provideSomething.
5. Modules that do work in their constructor
This is the one that is genuinely easy to miss, because it is not in any of the arrays. Some modules have a constructor, and that constructor does something.
The pattern we had was icon registration. A module would inject a registry service and register the set of icons its feature needed:
@NgModule({ /* ... */ })
export class ContactPageModule {
constructor(private readonly icons: IconRegistryService) {
this.icons.registerIcons(CONTACT_PAGE_ICONS);
}
}Delete that module and the icons stop being registered. There is no array for the schematic to rewrite and no way for it to know where the call should go instead, so it leaves the module alone.
The answer is to move the registration to whatever actually needs the icons. Usually that is the component itself, which is a better place for it anyway: the icons a component uses become visible in the component rather than in a module several directories away.
@Component({
selector: 'app-contact-page',
templateUrl: './contact-page.component.html',
})
export class ContactPageComponent {
private readonly icons = inject(IconRegistryService);
constructor() {
this.icons.registerIcons(CONTACT_PAGE_ICONS);
}
}If several components need the same set, register it once at application startup instead. Either way the module has nothing left to do and can finally be deleted.
6. Doing this without breaking the application
Standalone components and NgModule coexist perfectly well, which is the thing that makes this survivable. A standalone component can be imported into a module, and a module’s exports can be imported by a standalone component. You are never in a state where half a migration is broken.
The sequence that worked for us:
- Search for
forRootandModuleWithProvidersand convert the most-imported ones to provider functions. - Move module providers to
providedIn: 'root', or to component scope where the narrower lifetime is genuinely wanted. - Move constructor work out of modules and into the components or bootstrap code that needs it.
- Run the schematic again. This time it will have things to do.
- Repeat. Each pass unblocks the next layer.
The property worth protecting is that every one of those steps is shippable on its own. This migration takes longer than anyone plans for, and it will be interrupted, so it needs to survive being paused at any point.
7. What this unlocks
Getting to standalone is worth doing for its own sake, but it is also a prerequisite rather than a destination. Deferrable views need genuinely standalone, genuinely isolated components before they will produce a separate chunk, which I have written about in what to defer. The signal and zoneless migrations assume standalone throughout, which is why it is the first step in migrating a large Angular app to zoneless.
None of that is available while a SharedModule with three forRoot() calls is holding two hundred modules in place. It is unglamorous work, and it is the gate everything else is behind.
Sources and further reading
packages/core/schematics/collection.jsonat v22.1.3, for the current schematic names.packages/core/src/metadata/directives.tsat v22.1.3, wherestandaloneis documented as the flag you set tofalse.- The standalone migration README in the Angular repository.
- Angular documentation on standalone components and on dependency injection.