Angular 20+ Coding Standards Handbook
Modern, signal-first conventions for building scalable Angular applications — standalone components, new control flow, resource API, and zoneless change detection.
@if/@for/@switch replace *ngIf/*ngFor. Zone.js is optional. This handbook reflects these modern patterns exclusively.Project Structure
Use a feature-based layout. Each feature is self-contained with components, services, routes, and models. No shared modules — use barrel exports and the providedIn: 'root' pattern.
// Recommended structure — feature-based, standalone-first src/ ├── app/ │ ├── app.component.ts // Root component │ ├── app.config.ts // provideRouter, provideHttpClient, etc. │ ├── app.routes.ts // Top-level route config │ │ │ ├── core/ // Singleton services, guards, interceptors │ │ ├── auth/ │ │ │ ├── auth.service.ts │ │ │ ├── auth.guard.ts │ │ │ └── auth.interceptor.ts │ │ ├── api/ │ │ │ ├── api.service.ts │ │ │ └── error.interceptor.ts │ │ └── layout/ │ │ ├── header.component.ts │ │ └── sidebar.component.ts │ │ │ ├── features/ // Feature domains │ │ ├── users/ │ │ │ ├── user-list.component.ts │ │ │ ├── user-detail.component.ts │ │ │ ├── user.service.ts │ │ │ ├── user.model.ts │ │ │ └── users.routes.ts │ │ ├── dashboard/ │ │ │ ├── dashboard.component.ts │ │ │ ├── widgets/ │ │ │ │ ├── stats-card.component.ts │ │ │ │ └── chart.component.ts │ │ │ └── dashboard.routes.ts │ │ └── orders/ │ │ └── ... │ │ │ └── shared/ // Reusable UI components, pipes, directives │ ├── components/ │ │ ├── button.component.ts │ │ ├── modal.component.ts │ │ └── data-table.component.ts │ ├── directives/ │ │ └── tooltip.directive.ts │ ├── pipes/ │ │ └── date-format.pipe.ts │ └── models/ │ └── pagination.model.ts │ ├── environments/ │ ├── environment.ts │ └── environment.prod.ts ├── styles/ │ ├── _variables.scss │ ├── _mixins.scss │ └── global.scss ├── index.html └── main.ts
ng generate @angular/core:standalone to convert.Tooling & CLI
| Tool | Purpose | Config |
|---|---|---|
| Angular CLI | Scaffolding, build, serve, test, lint | angular.json |
| TypeScript 5.7+ | Strict mode, latest features | tsconfig.json |
| ESLint | Linting (via @angular-eslint) | eslint.config.mjs |
| Prettier | Code formatting | .prettierrc |
| Vitest / Jest | Unit testing | vitest.config.ts |
| Playwright | E2E testing | playwright.config.ts |
| Husky + lint-staged | Pre-commit hooks | .husky/ |
// tsconfig.json — strict configuration { "compilerOptions": { "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "target": "ES2023", "module": "ES2022", "moduleResolution": "bundler", "paths": { "@core/*": ["src/app/core/*"], "@features/*": ["src/app/features/*"], "@shared/*": ["src/app/shared/*"], "@env": ["src/environments/environment"] } } }
# Angular CLI commands
ng new my-app --style=scss --routing --ssr=false --standalone
ng generate component features/users/user-list --inline-style=false
ng generate service core/auth/auth
ng serve --port 4200
ng build --configuration=production
ng test --watch
ng lint
Signals & Reactivity
Signals are the primary reactivity primitive in Angular 20+. Use signal() for local state, computed() for derived values, and effect() for side effects. Prefer signals over BehaviorSubject/Observable for component state.
Use signal() for all mutable component state. Use computed() for derived values. Use RxJS only for async streams (HTTP, WebSocket, complex event composition).
// ✅ Signal-based component state import { signal, computed, effect } from '@angular/core'; export class UserListComponent { // Writable signals readonly searchQuery = signal(''); readonly selectedRole = signal<string | null>(null); readonly users = signal<User[]>([]); readonly isLoading = signal(false); // Computed — auto-tracks dependencies readonly filteredUsers = computed(() => { const query = this.searchQuery().toLowerCase(); const role = this.selectedRole(); return this.users().filter(u => u.name.toLowerCase().includes(query) && (!role || u.role === role) ); }); readonly userCount = computed(() => this.filteredUsers().length); // Effect — runs when tracked signals change private readonly logEffect = effect(() => { console.log(`Filtered: ${this.userCount()} users`); }); }
// ✅ linkedSignal — two-way derived state (Angular 20) import { signal, linkedSignal } from '@angular/core'; export class PaginationComponent { readonly totalItems = signal(0); readonly pageSize = signal(20); // Resets to 1 whenever pageSize changes, but is also writable readonly currentPage = linkedSignal({ source: this.pageSize, computation: () => 1, }); nextPage(): void { this.currentPage.update(p => p + 1); } }
// ✅ resource() — async data fetching with signals (Angular 20) import { resource, signal } from '@angular/core'; export class UserDetailComponent { readonly userId = signal(0); readonly userResource = resource({ request: () => ({ id: this.userId() }), loader: async ({ request }) => { const response = await fetch(`/api/users/${request.id}`); return response.json() as Promise<User>; }, }); // Template: userResource.value(), userResource.isLoading(), userResource.error() }
toSignal() in the component. If a service uses signals, don't pipe through RxJS.// Converting between signals and observables import { toSignal, toObservable } from '@angular/core/rxjs-interop'; // Observable → Signal readonly users = toSignal(this.userService.getAll(), { initialValue: [] }); // Signal → Observable (rare — only for RxJS-heavy pipes) readonly search$ = toObservable(this.searchQuery);
Component Standards
| Element | Convention | Example |
|---|---|---|
| Component selectors | app- prefix, kebab-case | app-user-list |
| Component class | PascalCase + Component suffix | UserListComponent |
| Service class | PascalCase + Service suffix | UserService |
| Directive selectors | app prefix, camelCase | appTooltip |
| Pipe names | camelCase | dateFormat |
| File names | kebab-case + type suffix | user-list.component.ts |
| Interface / Type | PascalCase (no I prefix) | User, OrderItem |
| Enum | PascalCase name, PascalCase members | UserRole.Admin |
| Constants | UPPER_SNAKE_CASE | MAX_PAGE_SIZE |
// ✅ Angular 20 standalone component — signal inputs/outputs import { Component, ChangeDetectionStrategy, input, output, computed, } from '@angular/core'; @Component({ selector: 'app-user-card', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: ` <article class="user-card" [class.active]="isActive()"> <h3>{{ user().name }}</h3> <span class="role">{{ user().role }}</span> <button (click)="selected.emit(user())">Select</button> </article> `, styleUrl: './user-card.component.scss', }) export class UserCardComponent { // Signal-based inputs (Angular 17.1+, stable in 20) readonly user = input.required<User>(); readonly highlightRole = input<string | undefined>(); // Output — typed event emitter readonly selected = output<User>(); // Computed from inputs readonly isActive = computed(() => this.user().role === this.highlightRole() ); }
input() and output() signal APIs (not @Input/@Output decorators).Use
ChangeDetectionStrategy.OnPush on every component.Use
input.required() for mandatory inputs.Keep components under 200 lines — extract to sub-components.
@Input() / @Output() decorators (legacy).Don't use
ngOnChanges — use computed() or effect().Don't use
Default change detection strategy.Don't use
NgModule — everything is standalone.Don't put business logic in components — delegate to services.
// ✅ viewChild / contentChild with signals (Angular 20) import { viewChild, contentChild, afterRender } from '@angular/core'; export class ChartComponent { // Signal-based queries — type-safe, no undefined checks readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas'); readonly legend = contentChild(LegendComponent); constructor() { afterRender(() => { // Safe — canvas is guaranteed available after render this.initChart(this.canvas().nativeElement); }); } }
Template Syntax
Angular 20 uses the new control flow syntax exclusively. Structural directives (*ngIf, *ngFor, *ngSwitch) are deprecated.
Naming in Templates
| Element | Convention | Example |
|---|---|---|
| Event handlers | on prefix, verb | (click)="onSelectUser(user)" |
| Boolean attributes | is/has/can prefix | [class.active]="isSelected()" |
| Template variables | camelCase | @let userName = user().name |
| Template refs | camelCase, descriptive | #searchInput |
Control Flow — @if / @for / @switch
<!-- ✅ @if with @else --> @if (isLoading()) { <app-spinner /> } @else if (error()) { <app-error-banner [message]="error()" /> } @else { <app-user-list [users]="users()" /> } <!-- ✅ @for with track — REQUIRED --> @for (user of filteredUsers(); track user.id) { <app-user-card [user]="user" (selected)="onSelectUser($event)" /> } @empty { <p class="no-results">No users found.</p> } <!-- ✅ @switch --> @switch (user().role) { @case ('admin') { <app-admin-badge /> } @case ('editor') { <app-editor-badge /> } @default { <app-user-badge /> } } <!-- ✅ @let — local template variables (Angular 18+) --> @let fullName = user().firstName + ' ' + user().lastName; @let itemCount = cart().items.length; <h2>{{ fullName }}</h2> <span>{{ itemCount }} items in cart</span>
track is mandatory on @for. Always track by a unique, stable identifier (e.g., track item.id). Never use track $index for mutable lists — it causes needless DOM destruction and recreation.ng generate @angular/core:control-flow to auto-migrate *ngIf/*ngFor/*ngSwitch to the new block syntax.Dependency Injection
Use inject() function (Angular 14+) instead of constructor injection. Use providedIn: 'root' for singleton services.
// ✅ inject() function — preferred in Angular 20 import { inject } from '@angular/core'; export class UserListComponent { private readonly userService = inject(UserService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); } // ❌ Don't — constructor injection (still works, but verbose) export class UserListComponent { constructor( private readonly userService: UserService, private readonly router: Router, ) {} }
// app.config.ts — application-level providers import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { authInterceptor } from '@core/auth/auth.interceptor'; import { errorInterceptor } from '@core/api/error.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes, withComponentInputBinding()), provideHttpClient( withInterceptors([authInterceptor, errorInterceptor]), ), ], };
// InjectionToken for configuration import { InjectionToken } from '@angular/core'; export interface AppConfig { apiBaseUrl: string; featureFlags: Record<string, boolean>; } export const APP_CONFIG = new InjectionToken<AppConfig>('app-config'); // Provide in app.config.ts providers: [ { provide: APP_CONFIG, useValue: { apiBaseUrl: '/api/v1', featureFlags: {} } }, ] // Inject it private readonly config = inject(APP_CONFIG);
Services & State Management
Use signal-based services for state. Keep services focused — one domain per service. Use providedIn: 'root' for app-wide singletons.
// ✅ Signal-based state service import { Injectable, signal, computed } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class CartService { private readonly _items = signal<CartItem[]>([]); // Public read-only access readonly items = this._items.asReadonly(); readonly itemCount = computed(() => this._items().length); readonly total = computed(() => this._items().reduce((sum, item) => sum + item.price * item.quantity, 0) ); addItem(product: Product, quantity = 1): void { this._items.update(items => { const existing = items.find(i => i.productId === product.id); if (existing) { return items.map(i => i.productId === product.id ? { ...i, quantity: i.quantity + quantity } : i ); } return [...items, { productId: product.id, name: product.name, price: product.price, quantity }]; }); } removeItem(productId: string): void { this._items.update(items => items.filter(i => i.productId !== productId)); } clear(): void { this._items.set([]); } }
Routing & Guards
Use functional route definitions with lazy-loaded feature routes. Use functional guards (not class-based). Bind route params to component inputs.
// app.routes.ts — top-level lazy routes import { Routes } from '@angular/router'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full', }, { path: 'dashboard', loadComponent: () => import('@features/dashboard/dashboard.component') .then(m => m.DashboardComponent), }, { path: 'users', loadChildren: () => import('@features/users/users.routes') .then(m => m.usersRoutes), canActivate: [authGuard], }, { path: '**', loadComponent: () => import('@shared/components/not-found.component') .then(m => m.NotFoundComponent), }, ];
// features/users/users.routes.ts — child routes import { Routes } from '@angular/router'; export const usersRoutes: Routes = [ { path: '', loadComponent: () => import('./user-list.component').then(m => m.UserListComponent), }, { path: ':id', loadComponent: () => import('./user-detail.component').then(m => m.UserDetailComponent), }, ];
// ✅ Functional guard (Angular 15+) import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; export const authGuard: CanActivateFn = () => { const authService = inject(AuthService); const router = inject(Router); if (authService.isAuthenticated()) { return true; } return router.createUrlTree(['/login']); };
// ✅ Route params as component inputs (withComponentInputBinding) export class UserDetailComponent { // Automatically bound from route param ':id' readonly id = input.required<string>(); private readonly userResource = resource({ request: () => ({ id: this.id() }), loader: async ({ request }) => { const res = await fetch(`/api/users/${request.id}`); return res.json(); }, }); }
Forms & Validation
Use Reactive Forms for complex forms with validation. Use template-driven forms only for trivial inputs. Always type your FormGroups.
// ✅ Typed Reactive Forms (Angular 14+) import { Component, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; @Component({ selector: 'app-user-form', standalone: true, imports: [ReactiveFormsModule], template: ` <form [formGroup]="form" (ngSubmit)="onSubmit()"> <label for="email">Email</label> <input id="email" formControlName="email" type="email" /> @if (form.controls.email.errors?.['email']) { <span class="error">Invalid email</span> } <label for="name">Full Name</label> <input id="name" formControlName="fullName" /> <label for="role">Role</label> <select id="role" formControlName="role"> @for (role of roles; track role) { <option [value]="role">{{ role }}</option> } </select> <button type="submit" [disabled]="form.invalid">Save</button> </form> `, }) export class UserFormComponent { private readonly fb = inject(FormBuilder); readonly roles = ['admin', 'editor', 'viewer'] as const; readonly form = this.fb.nonNullable.group({ email: ['', [Validators.required, Validators.email]], fullName: ['', [Validators.required, Validators.minLength(2)]], role: ['viewer' as typeof this.roles[number]], }); onSubmit(): void { if (this.form.valid) { const value = this.form.getRawValue(); // value is fully typed: { email: string; fullName: string; role: string } console.log(value); } } }
- Always use
nonNullableform builder — avoidsnullin form values - Type your form controls —
FormControl<string>notFormControl<string | null> - Use
getRawValue()— includes disabled control values, fully typed - Create custom validators as functions, not classes
HTTP & API Layer
Centralize HTTP calls in services. Use functional interceptors. Type all API responses. Handle errors globally via interceptor.
// core/api/api.service.ts — typed HTTP wrapper import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { APP_CONFIG } from '@core/config'; @Injectable({ providedIn: 'root' }) export class ApiService { private readonly http = inject(HttpClient); private readonly config = inject(APP_CONFIG); get<T>(path: string, params?: Record<string, string>): Observable<T> { return this.http.get<T>(`${this.config.apiBaseUrl}${path}`, { params: new HttpParams({ fromObject: params ?? {} }), }); } post<T>(path: string, body: unknown): Observable<T> { return this.http.post<T>(`${this.config.apiBaseUrl}${path}`, body); } put<T>(path: string, body: unknown): Observable<T> { return this.http.put<T>(`${this.config.apiBaseUrl}${path}`, body); } delete<T>(path: string): Observable<T> { return this.http.delete<T>(`${this.config.apiBaseUrl}${path}`); } }
// ✅ Functional interceptor — auth token injection import { HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const token = authService.token(); if (token) { req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` }, }); } return next(req); };
// ✅ Functional interceptor — global error handling import { HttpInterceptorFn } from '@angular/common/http'; import { catchError, throwError } from 'rxjs'; export const errorInterceptor: HttpInterceptorFn = (req, next) => { const notificationService = inject(NotificationService); return next(req).pipe( catchError(error => { if (error.status === 401) { inject(AuthService).logout(); } else if (error.status >= 500) { notificationService.error('Server error. Please try again.'); } return throwError(() => error); }), ); };
Styling Standards
- Use SCSS as the preprocessor (configured at project creation)
- Component styles are encapsulated — use
styleUrlper component - Global styles go in
styles/global.scss— theme variables, resets, typography - Use CSS custom properties for theming, not SCSS variables for runtime values
- Follow BEM naming for custom CSS classes:
.user-card__header--active - No
::ng-deep— it's deprecated. Use CSS custom properties or:host-context - Use
:hostfor component-level styling.:host { display: block; }
// user-card.component.scss
:host {
display: block;
}
.user-card {
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 8px;
transition: border-color 0.2s;
&:hover {
border-color: var(--accent-color);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__name {
font-weight: 600;
font-size: 1rem;
}
&--active {
border-color: var(--accent-color);
background: var(--active-bg);
}
}
Accessibility (a11y)
<button> not <div (click)>.Use
<nav>, <main>, <article>, <section>.Use heading hierarchy (
h1→h2→h3).
Visible focus indicators on all focusable elements.
Use
cdkTrapFocus in modals/dialogs.
aria-label for icon-only buttons.Use
aria-live="polite" for dynamic content.Use
role only when no semantic HTML equivalent exists.
Don't rely solely on color for information.
Support
prefers-reduced-motion and prefers-color-scheme.
<!-- ✅ Accessible interactive elements --> <button type="button" (click)="onDelete()" aria-label="Delete user {{ user().name }}" > <svg aria-hidden="true">...</svg> </button> <!-- ✅ Live region for dynamic updates --> <div aria-live="polite" class="sr-only"> @if (saveSuccess()) { User saved successfully. } </div> <!-- ✅ Form labels --> <label for="search">Search users</label> <input id="search" type="search" [formControl]="searchControl" />
Internationalization (i18n)
- Use Angular's built-in i18n with
i18nattribute on elements - Extract messages with
ng extract-i18n - Use ICU expressions for plurals and select
- No hardcoded user-facing strings in TypeScript — use translation services or templates
- Dates, numbers, currencies — always use Angular pipes (
DatePipe,CurrencyPipe) with locale
<!-- Mark text for translation --> <h1 i18n="Page title|Title for user management page">User Management</h1> <!-- ICU plural --> <span i18n> {userCount, plural, =0 {No users found} =1 {1 user found} other {{{userCount}} users found} } </span> <!-- Localized date --> <time>{{ user().createdAt | date:'mediumDate' }}</time>
Testing Strategy
Test pyramid: unit tests (services, pipes) → component tests (harness) → integration tests (routes) → E2E tests (Playwright). Target 80%+ coverage.
// ✅ Component test with TestBed — Angular 20 style import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { UserListComponent } from './user-list.component'; import { UserService } from './user.service'; describe('UserListComponent', () => { let fixture: ComponentFixture<UserListComponent>; let component: UserListComponent; let httpMock: HttpTestingController; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [UserListComponent], providers: [ provideHttpClient(), provideHttpClientTesting(), ], }).compileComponents(); fixture = TestBed.createComponent(UserListComponent); component = fixture.componentInstance; httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); }); it('should display users after loading', () => { const mockUsers = [ { id: 1, name: 'Alice', role: 'admin' }, { id: 2, name: 'Bob', role: 'editor' }, ]; fixture.detectChanges(); const req = httpMock.expectOne('/api/v1/users'); req.flush(mockUsers); fixture.detectChanges(); const cards = fixture.nativeElement.querySelectorAll('app-user-card'); expect(cards.length).toBe(2); }); it('should filter users by search query', () => { component.users.set([ { id: 1, name: 'Alice', role: 'admin' }, { id: 2, name: 'Bob', role: 'editor' }, ]); component.searchQuery.set('ali'); expect(component.filteredUsers().length).toBe(1); expect(component.filteredUsers()[0].name).toBe('Alice'); }); });
// ✅ Service test describe('CartService', () => { let service: CartService; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(CartService); }); it('should add item to cart', () => { service.addItem({ id: '1', name: 'Widget', price: 9.99 }); expect(service.itemCount()).toBe(1); expect(service.total()).toBe(9.99); }); it('should increment quantity for existing item', () => { const product = { id: '1', name: 'Widget', price: 9.99 }; service.addItem(product); service.addItem(product); expect(service.itemCount()).toBe(1); expect(service.items()[0].quantity).toBe(2); expect(service.total()).toBeCloseTo(19.98); }); });
# Run tests ng test # unit tests (Karma/Vitest) ng test --code-coverage # with coverage report npx playwright test # E2E tests
Performance Optimization
ChangeDetectionStrategy.OnPush. Signals + OnPush = minimal change detection cycles.loadComponent / loadChildren. Keeps initial bundle under 200KB gzipped.@defer (on viewport) or @defer (on interaction). Automatic code splitting.track item.id — never track $index for mutable arrays. Prevents DOM thrashing.NgOptimizedImage directive for automatic lazy loading, srcset, and preventing CLS.provideExperimentalZonelessChangeDetection(). Requires signal-only architecture.<!-- ✅ @defer — lazy-load heavy components --> @defer (on viewport) { <app-analytics-chart [data]="chartData()" /> } @placeholder { <div class="chart-skeleton"></div> } @loading (minimum 300ms) { <app-spinner size="lg" /> } @error { <p>Failed to load chart.</p> } <!-- Defer on interaction --> @defer (on interaction) { <app-comment-section [postId]="post().id" /> } @placeholder { <button>Load comments</button> } <!-- Defer with timer --> @defer (on timer(2s)) { <app-recommendations /> }
// ✅ NgOptimizedImage import { NgOptimizedImage } from '@angular/common'; @Component({ imports: [NgOptimizedImage], template: ` <img ngSrc="/assets/hero.jpg" width="800" height="400" priority placeholder /> `, })
Security
Never use
bypassSecurityTrust* without review.Never bind user input to
[innerHTML] without sanitization.
httpOnly cookies (preferred) or memory.Never store JWTs in
localStorage — vulnerable to XSS.Use interceptor for token attachment.
HttpClientXsrfModule for CSRF token handling.Or use
withXsrfConfiguration() with provideHttpClient.
Avoid
unsafe-inline for scripts.Use
nonce attributes for inline styles if needed.
// ✅ CSRF configuration import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http'; providers: [ provideHttpClient( withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN', }), withInterceptors([authInterceptor]), ), ]
bypassSecurityTrustHtml() should be treated as security-critical code that requires team review. Always sanitize on the server side as well.Build & Deployment
# Production build ng build --configuration=production # Build outputs to dist/ — optimized, tree-shaken, minified
// angular.json — production configuration { "configurations": { "production": { "budgets": [ { "type": "initial", "maximumWarning": "250kB", "maximumError": "500kB" }, { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" } ], "outputHashing": "all", "sourceMap": false, "optimization": true } } }
# Dockerfile — multi-stage nginx FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npx ng build --configuration=production FROM nginx:alpine AS runtime COPY --from=build /app/dist/my-app/browser /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80
# nginx.conf — SPA routing + security headers server { listen 80; root /usr/share/nginx/html; index index.html; # SPA fallback location / { try_files $uri $uri/ /index.html; } # Cache static assets aggressively location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 1y; add_header Cache-Control "public, immutable"; } # Security headers add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header X-XSS-Protection "1; mode=block" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always; }
ng build --stats-json and npx webpack-bundle-analyzer dist/my-app/stats.json to visualize bundle composition. Keep initial bundle under 200KB gzipped for fast loads.Reference Links
Official Documentation
- Angular angular.dev — Official docs
- Signals angular.dev/guide/signals
- Control flow angular.dev — Control flow
- Standalone angular.dev — Standalone components
- DI angular.dev/guide/di
- Forms angular.dev/guide/forms
- Routing angular.dev/guide/routing
- Testing angular.dev/guide/testing
- Security angular.dev — Security best practices
- Style guide angular.dev/style-guide
Angular 20 Specific
- resource() angular.dev — Resource API
- linkedSignal angular.dev — linkedSignal
- @defer angular.dev — Deferrable views
- Signal inputs angular.dev — Signal inputs
- Zoneless angular.dev — Zoneless change detection
Tooling & Libraries
- ESLint angular-eslint — ESLint for Angular
- NgRx ngrx.io/guide/signals — Signal Store
- Playwright playwright.dev — E2E testing
- CDK Angular CDK — UI primitives
- Material Angular Material — UI components