NgModule confused almost everyone who encountered Angular for the first time. I watched senior React developers spend two days understanding why a component they declared in FeatureModule was not available in another FeatureModule. The answer involved SharedModule, forFeature(), and a dependency graph that was completely invisible at the component level. Standalone components fix this by eliminating the indirection.
What Standalone Actually Means
A standalone component declares its own dependencies directly in its imports array. No NgModule wrapper. No declaration array somewhere else. If a component needs RouterLink, it imports RouterLink. If it needs CurrencyPipe, it imports CurrencyPipe. The component file is self-describing.
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AccountCardComponent } from './account-card.component';
import { Account } from './account.model';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
RouterLink,
AccountCardComponent
],
template: `
<h1>Dashboard</h1>
<a routerLink="/accounts">All accounts</a>
@for (account of accounts; track account.id) {
<app-account-card [account]="account" />
}
`
})
export class DashboardComponent {
accounts: Account[] = [];
}Notice that AccountCardComponent is imported directly. It does not need to be in a shared module. The import itself tells you: this component uses AccountCardComponent. No tracing through module declarations required. Note that the imports array carries only what the template actually uses. Anything else is dead weight, and Angular's NG8113 diagnostic will tell you about it.
Bootstrapping Without AppModule
New projects generated with ng new no longer create AppModule. They use bootstrapApplication with an ApplicationConfig.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch(console.error);
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
};Per-Component Lazy Loading
Before standalone, the smallest lazy-loadable unit was a module. If you wanted to lazy-load a single page component, you had to wrap it in a module first. Now you use loadComponent directly.
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'accounts',
loadChildren: () =>
import('./features/accounts/accounts.routes')
.then(m => m.ACCOUNTS_ROUTES)
}
];For feature areas with multiple routes, I still group them in a routes file and use loadChildren. That keeps the chunk boundary at the feature level. For individual pages that are genuinely standalone, loadComponent is the right call.
Scoped Providers Without Modules
NgModule providers were how you scoped services to a lazy-loaded feature. Without NgModule, you scope providers to a route via the providers array on the route definition.
{
path: 'lending',
loadComponent: () =>
import('./features/lending/lending.component').then(m => m.LendingComponent),
providers: [
LendingService,
{ provide: LENDING_API_URL, useValue: '/api/v2/lending' }
]
}In this example, the LendingService is scoped to the lending feature route. It gets created the first time the route activates, and nothing outside the lending feature can inject it. One catch: by default Angular does not destroy a route's injector when you navigate away, so the service and its state live on for the life of the app. Angular 21.1 added withExperimentalAutoCleanupInjectors, which destroys a route injector once that route leaves the active route tree, but it is experimental and opt-in. Until you turn it on, reset feature state explicitly when the route is entered instead of assuming a fresh instance.
Migrating an Existing Project
The Angular team ships schematics that automate most of the migration. Run them in sequence.
- ng generate @angular/core:standalone --mode=convert-to-standalone: converts declarations to standalone
- ng generate @angular/core:standalone --mode=prune-ng-modules: removes empty modules after conversion
- ng generate @angular/core:standalone --mode=standalone-bootstrap: updates main.ts to use bootstrapApplication
- Review the diff carefully for provider setup, especially if you use forRoot() patterns
- Update TestBed: standalone components use imports instead of declarations in configureTestingModule
At Wonderlabs, I moved the credit card platform from Angular 13 to Angular 15 in a monorepo. Standalone APIs became stable in 15.0 and the standalone migration schematic arrived in 15.2, so that version line is where a conversion like this becomes practical. The schematic handles most of the work. What remains is provider configuration in AppModule that needs manual translation to provideX() functions. Budget half a day per project.
The Practical Impact
After migrating, the most immediate change is onboarding speed. A new developer can understand what a component depends on by reading the component file. They no longer need to know which module it lives in, which SharedModule exports CommonModule, and why RouterModule needs to be imported in three places. That cognitive overhead was real, and it is gone.
Build size also improves in projects where module boundaries were coarse. Tree-shaking is more precise when the import graph lives at the component level. In a feature area that leans on an overly broad SharedModule, the initial bundle can get noticeably smaller after migration just from eliminating the imports that module was pulling in.
Standalone components did not add new capabilities. They removed indirection. That is a harder engineering problem to solve, and it matters more.
References
- Angular standalone components. https://angular.dev/guide/components/importing
- bootstrapApplication API. https://angular.dev/api/platform-browser/bootstrapApplication
- Standalone migration schematic. https://angular.dev/reference/migrations/standalone
- Testing standalone components. https://angular.dev/guide/testing/components-basics