Back to handbooks index

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.

Angular 20+ TypeScript 5.7+ · Strict Mode Signals · Standalone · Zoneless March 2026
Angular 20+ paradigm shift: Standalone components are the default (no NgModules). Signals replace RxJS for state management. @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
💡
No NgModules. Angular 20 defaults to standalone. Every component, directive, and pipe is standalone. If migrating, run ng generate @angular/core:standalone to convert.

Tooling & CLI

ToolPurposeConfig
Angular CLIScaffolding, build, serve, test, lintangular.json
TypeScript 5.7+Strict mode, latest featurestsconfig.json
ESLintLinting (via @angular-eslint)eslint.config.mjs
PrettierCode formatting.prettierrc
Vitest / JestUnit testingvitest.config.ts
PlaywrightE2E testingplaywright.config.ts
Husky + lint-stagedPre-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.

Rule: Signal-First State Management Angular 20+

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()
}
Don't mix paradigms. Avoid wrapping signals in Observables or vice versa within the same component. If a service returns an Observable (HTTP), use 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

ElementConventionExample
Component selectorsapp- prefix, kebab-caseapp-user-list
Component classPascalCase + Component suffixUserListComponent
Service classPascalCase + Service suffixUserService
Directive selectorsapp prefix, camelCaseappTooltip
Pipe namescamelCasedateFormat
File nameskebab-case + type suffixuser-list.component.ts
Interface / TypePascalCase (no I prefix)User, OrderItem
EnumPascalCase name, PascalCase membersUserRole.Admin
ConstantsUPPER_SNAKE_CASEMAX_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()
  );
}
Do
Use 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.
Don't
Don't use @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

ElementConventionExample
Event handlerson prefix, verb(click)="onSelectUser(user)"
Boolean attributesis/has/can prefix[class.active]="isSelected()"
Template variablescamelCase@let userName = user().name
Template refscamelCase, 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.
💡
Migrate from structural directives: Run 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([]);
  }
}
When to use NgRx / NGXS: Signal-based services suffice for most apps. Use NgRx Signal Store for complex cross-feature state with devtools, entity management, or undo/redo requirements. Avoid NgRx for small-to-medium apps.

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);
    }
  }
}

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

// 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)

🏷️
Semantic HTML
Use <button> not <div (click)>.
Use <nav>, <main>, <article>, <section>.
Use heading hierarchy (h1h2h3).
⌨️
Keyboard Navigation
All interactive elements must be keyboard-accessible.
Visible focus indicators on all focusable elements.
Use cdkTrapFocus in modals/dialogs.
📢
ARIA Attributes
Use aria-label for icon-only buttons.
Use aria-live="polite" for dynamic content.
Use role only when no semantic HTML equivalent exists.
🎨
Visual
Minimum 4.5:1 contrast ratio (WCAG AA).
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)

<!-- 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

OnPush Everywhere
Every component uses ChangeDetectionStrategy.OnPush. Signals + OnPush = minimal change detection cycles.
Lazy Load Routes
All feature routes use loadComponent / loadChildren. Keeps initial bundle under 200KB gzipped.
@defer Blocks
Defer heavy components with @defer (on viewport) or @defer (on interaction). Automatic code splitting.
Track in @for
Always use track item.id — never track $index for mutable arrays. Prevents DOM thrashing.
Image Optimization
Use NgOptimizedImage directive for automatic lazy loading, srcset, and preventing CLS.
Zoneless (Experimental)
Drop Zone.js entirely with 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

🛡️
XSS Protection
Angular auto-sanitizes template bindings.
Never use bypassSecurityTrust* without review.
Never bind user input to [innerHTML] without sanitization.
🔐
Auth Token Handling
Store tokens in httpOnly cookies (preferred) or memory.
Never store JWTs in localStorage — vulnerable to XSS.
Use interceptor for token attachment.
🚫
CSRF Protection
Use HttpClientXsrfModule for CSRF token handling.
Or use withXsrfConfiguration() with provideHttpClient.
🔒
Content Security Policy
Configure CSP headers on server.
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]),
  ),
]
🚨
Never disable Angular's built-in sanitization. Methods like 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;
}
💡
Monitor bundle size. Use 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 20 Specific

Tooling & Libraries