Angular standalone components eliminate the need for NgModule, allowing components, directives, and pipes to directly declare their dependencies via their own imports array. Combined with bootstrapApplication, provideRouter, and provideHttpClient, standalone architecture simplifies dependency injection, enhances bundle tree-shaking, and makes lazy loading straightforward.
Traditional NgModule Architecture:
AppModule ──► imports FeatureModule ──► declares OrderComponent
│
└── imports SharedModule (pulls in unused components/pipes)
Standalone Component Architecture:
main.ts: bootstrapApplication(AppComponent, { providers })
│
▼
routes.ts: loadComponent: () => import('./order.component')
│
▼
OrderComponent (standalone: true)
imports: [CommonModule, RouterLink, CurrencyPipe, OrderSummaryComponent]
New to this → start with bootstrapping and routing. Migrating an existing codebase → check environment providers vs module providers. API & Auth integration → Angular and .NET integration. Interview prep → If an interviewer asks.
Real-world analogy
Think of an NgModule as a centralized bureaucratic department where every employee (component, directive, pipe) must register before they can work in any office. Even if a feature only needs a single calculator (pipe), the department imports the entire stationery supplies catalog (SharedModule).
A standalone component is a self-sufficient contractor with their own badge and toolbox: they declare precisely the tools they need in their imports list. The building infrastructure (the router, HTTP client, and global auth services) remains shared via application-level environment providers.
Environment providers and application bootstrap
In standalone Angular applications, main.ts bootstraps the root component using bootstrapApplication without an AppModule. Global configuration is supplied via the ApplicationConfig providers array.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
import { loggingInterceptor } from './app/core/interceptors/logging.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(
routes,
withComponentInputBinding(),
withViewTransitions()
),
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor])
),
],
}).catch((err) => console.error('Bootstrap error:', err));
Key advantages over NgModule bootstrap:
- Tree-shaking: Features and pipes not imported by any active component are completely eliminated by the bundler.
- Explicit dependencies: Reading a component's TypeScript decorator instantly reveals every child component, directive, and pipe it utilizes.
- No hidden module bloat: Avoids massive
SharedModulebundles that bloat initial chunk sizes.
Declaring and using standalone components
In Angular 17+, standalone: true is the default when using the Angular CLI (ng g c my-feature). Components explicitly import what their template renders:
// src/app/features/orders/order-detail.component.ts
import { Component, inject, input, OnInit, signal } from '@angular/core';
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { OrderService, OrderDto } from '../../core/services/order.service';
import { StatusBadgeComponent } from '../../shared/ui/status-badge.component';
@Component({
selector: 'app-order-detail',
standalone: true,
imports: [
CommonModule,
RouterLink,
CurrencyPipe,
DatePipe,
StatusBadgeComponent,
],
template: `
@if (order(); as orderData) {
<div class="order-card">
<header class="flex justify-between items-center">
<h2>Order #{{ orderData.id }}</h2>
<app-status-badge [status]="orderData.status" />
</header>
<p>Placed on: {{ orderData.createdAt | date:'medium' }}</p>
<p class="font-bold">Total: {{ orderData.totalAmount | currency:'USD' }}</p>
<a [routerLink]="['/orders']" class="btn-secondary">Back to Orders</a>
</div>
} @else {
<p class="loading-state">Loading order details...</p>
}
`,
})
export class OrderDetailComponent implements OnInit {
private readonly orderService = inject(OrderService);
// Router input binding automatically binds :id route parameter
readonly id = input.required<string>();
readonly order = signal<OrderDto | null>(null);
ngOnInit(): void {
this.orderService.getOrderById(this.id()).subscribe({
next: (data) => this.order.set(data),
error: (err) => console.error('Failed to load order', err),
});
}
}
Routing and lazy-loading with loadComponent and loadChildren
Standalone routing uses loadComponent for single components and loadChildren with route definition files for feature modules:
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard',
},
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'orders',
canActivate: [authGuard],
// Lazy-load a child routing file containing related sub-routes
loadChildren: () =>
import('./features/orders/orders.routes').then((m) => m.ORDER_ROUTES),
},
{
path: '**',
loadComponent: () =>
import('./shared/pages/not-found.component').then((m) => m.NotFoundComponent),
},
];
Inside features/orders/orders.routes.ts:
// src/app/features/orders/orders.routes.ts
import { Routes } from '@angular/router';
import { OrderListComponent } from './order-list.component';
import { OrderDetailComponent } from './order-detail.component';
import { OrderAnalyticsService } from './services/order-analytics.service';
export const ORDER_ROUTES: Routes = [
{
path: '',
// Route-level provider: scoped to orders feature subtree
providers: [OrderAnalyticsService],
children: [
{
path: '',
component: OrderListComponent,
},
{
path: ':id',
component: OrderDetailComponent,
},
],
},
];
Dependency injection scoping in standalone apps
Understanding the hierarchy between Root Environment Providers, Route-level Environment Providers, and Element Providers is vital in standalone architecture:
| Injection Level | Registration Point | Lifecycle & Scope | Use Case |
|---|---|---|---|
| Root Environment | bootstrapApplication({ providers: [...] }) or @Injectable({ providedIn: 'root' }) | App lifetime. Single global instance across all routes. | Auth services, API clients, global state, telemetry. |
| Route Environment | Routes definition: { path: '...', providers: [...] } | Route subtree lifetime. Created when route activates, destroyed when navigating away. | Feature-scoped state machines, feature cache, route analytics. |
| Element / Component | @Component({ providers: [...] }) | Component DOM instance lifetime. | Form adapters, component-level UI state, canvas controllers. |
Common mistakes and pitfalls
- Mixing
HttpClientModuleandprovideHttpClient: If a legacy library or legacy module importsHttpClientModule, it can override or duplicate interceptor pipelines registered withprovideHttpClient(). Always removeHttpClientModule. - Importing entire modules when only one directive is needed: Importing
FormsModulewhen onlyReactiveFormsModuleor a single pipe is required defeats the purpose of standalone tree-shaking. - Forgetting common directives in templates: Omitting
RouterLink,DatePipe, orCommonModulefrom a component'simportsresults in silent template binding failures orNG0302runtime errors. - Duplicating service state across lazy routes: Registering a service in a component's
providers: [CartService]instead ofprovidedIn: 'root'creates a separate cart instance per component instead of a single shared shopping cart. - Circular dependencies across standalone imports: Component A importing Component B in
importswhile Component B imports Component A creates circular TypeScript references. Extract shared UI into child components or shared interfaces.
If an interviewer asks
30-second answer: Standalone components eliminate NgModule ceremony by allowing components to directly declare their dependencies via their imports array. Combined with bootstrapApplication, provideRouter, and provideHttpClient, they enable superior tree-shaking, cleaner lazy loading via loadComponent, and explicit dependency graphs without monolithic SharedModule bloat.
Strong answer: Standalone architecture shifts Angular from a module-centric compilation unit to a component-centric one. In production, it removes intermediate module overhead, simplifies route-based code splitting, and replaces NgModule.providers with structured EnvironmentInjector hierarchies at the app or route level. When migrating legacy applications, the golden rule is migrating leaf components first, switching to functional router guards and HTTP interceptors, and ensuring HttpClientModule is never mixed with provideHttpClient().