TopFans web frontend. Five majors to the current LTS, two bundlers, one SSR engine swap, one PrimeNG theme-system swap. NgModules and zone.js stay where they are.

What moved

ThingBeforeAfter
Angular15.2.1020.3.31
TypeScript4.9.5, moduleResolution: node5.8.3, bundler
zone.js0.110.15
rxjs7.57.8
Nodenode:20-slim, unpinned, CI on 1820.19.6 pinned, CI and functions on 20
Client builderbrowser (webpack)application (esbuild)
SSR engine@nguniversal ngExpressEngine@angular/ssr CommonEngine
Server bundleserver/main.js (CJS)server/server.mjs (ESM)
PrimeNG15.4, saga-blue theme CSS20.4, Lara preset from @primeuix/themes
Angular Material15.220.2
Component flagimplicitstandalone: false, explicit
Vendor CSSall in styles.css12 lazy bundles, loaded per widget

Where the effort went

webpackesbuild Stricter about module shape. Code that quietly relied on require() failed at runtime, not at build.
library APIsnew majors PrimeNG MultiSelect, Material slider, ngx-mask and ngx-audio-player each changed under us.
PrimeNG theme CSSJS preset PrimeNG 20 ships no theme stylesheets and renamed its classes. Every override we had written against saga-blue had to be rewritten against Lara.

Not done, on purpose: standalone components, signals. Last page.

Order of work

Framework
15 to 16
  • Follow-up for API migrations ng update missed
Framework
16 to 17
  • @nguniversal to @angular/ssr
  • PrimeNG 17 and Material 17 fixes
Bundler, still on 17
webpack to browser-esbuild
  • require() crashes
  • Import cycle
Bundler, still on 17
browser-esbuild to application
  • One build target, ESM server
  • Prerender bugs, IVS import
Framework
17 to 18
  • Seven files
  • DOMPurify interop
Framework
18 to 19
  • standalone: false, 490 times
  • SSR allowedHosts
  • Node 20 in CI
Build, on 19
Vendor CSS
  • 12 sheets out of styles.css
  • Loaded by the widget
Framework, LTS
19 to 20
  • PrimeNG 17 to 20, Lara
  • DOCUMENT from core
  • Class renames, carousel

One change per bump: versions and schematic migrations only. Fixes land separately. The bundler moved on a fixed framework version, so every breakage points at exactly one cause. Each page ahead has the same shape: what changed, then a slide on what broke. We stop at 20: it is on LTS into 2027, and 21 and 22 add nothing this app needs yet.

The smallest step. Mostly removing deprecated shapes the framework had been warning about since 15.2. Still on webpack and @nguniversal.

Environment and config

ThingBeforeAfter
Nodenode:20-slim, floating20.19.6 in .nvmrc and Dockerfile
TypeScript4.9.55.1.6
zone.js0.110.13
rxjs7.57.8
zone importzone.js/dist/zone-nodezone.js/node
typeRoots@typesnode_modules/@types
buildOptimizerimpliedexplicit per configuration
angular-eslint15.216.3, plus builder, schematics, template-parser
server.ts, serverless.ts- import 'zone.js/dist/zone-node';+ import 'zone.js/node';Dockerfile- FROM public.ecr.aws/docker/library/node:20-slim+ FROM public.ecr.aws/docker/library/node:20.19.6-slim.nvmrc (new)+ 20.19.6angular.json, every configuration+ "buildOptimizer": true

Framework API changes

class AuthGuard implements CanActivateclass AuthGuard Nine guards, plus one CanDeactivate<T>. The interfaces are deprecated; the router calls canActivate() by name, so the classes keep working unchanged.
entryComponents: [...]removed Ivy has ignored it since Angular 9. 16 rejects it. Only SharedModule still had one.
BrowserModule.withServerTransition({ appId })BrowserModule The app id is no longer needed for the server to client handoff.
import { TransferState } from '@angular/platform-browser'from '@angular/core' Same for makeStateKey. Three services: ApiCacheService, PlatformService, CacheService.
src/app/core/auth-guard.ts- import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';+ import { ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';- export class AuthGuard implements CanActivate {+ export class AuthGuard {src/app/app.module.ts- BrowserModule.withServerTransition({ appId: 'serverApp' }),+ BrowserModule,src/app/core/cache/api-cache.service.ts- import { Injectable } from '@angular/core';- import { TransferState, makeStateKey } from '@angular/platform-browser';+ import { Injectable, TransferState, makeStateKey } from '@angular/core';

Third-party libraries

LibraryFromToCode change
ngx-mask1216Module became directive + pipe + provideNgxMask()
primeng15.416.9MultiSelect removeChip is removeOption
ngx-drag-and-drop-lists1.3removedDndListModule was imported and unused
ngx-bootstrap811none
ng-bootstrap1215none
ng-select811none
ngrx/store1316none
ngx-material-timepicker513none
stripe-js / ngx-stripe1 / 133 / 16none
angular-fontawesome0.70.13none
domino, luxontransitiveexplicitnone
src/app/modules/wallet/wallet.module.ts- import { NgxMaskModule, IConfig } from 'ngx-mask';+ import { NgxMaskDirective, NgxMaskPipe, provideNgxMask, IConfig } from 'ngx-mask';  imports: [-   NgxMaskModule.forRoot(),+   NgxMaskDirective,+   NgxMaskPipe,  ],- providers: [DatePipe, WalletHomeService]+ providers: [DatePipe, WalletHomeService, provideNgxMask()]custom-multiselect.component.ts- multiSelect.removeChip(item, event);+ multiSelect.removeOption(item, event);

What broke

Symptom
Build green after the bump. Deprecated APIs still live in five files.
Cause
ng update schematics do not rewrite every pattern, and deprecations compile without complaint.
Fix
A follow-up change:
  • WhitelabelSiteOnlyGuard still implementing CanActivate
  • TransferState still from platform-browser in three services
  • withServerTransition still in AppModule
Rule from here: grep for the deprecated symbol after every ng update.
the follow-up, 5 files  src/app/app.module.ts                        | 2 +-  src/app/core/cache/api-cache.service.ts      | 3 +--  src/app/core/services/platform.service.ts    | 9 +++++++--  src/app/core/whitelabel-site-only.guard.ts   | 4 ++--  src/app/modules/shared/services/cache.service.ts | 3 +--

The SSR engine swap. @nguniversal is gone and @angular/ssr takes its place. The client build stays on webpack for now.

Environment and config

ThingBeforeAfter
Node20.19.6keep, Node 18 is end of life
TypeScript5.1.65.4.5
zone.js0.130.14
ESLint8.128.57
typescript-eslint57
angular-eslint16.317.5
.gitignore+20 patterns for generated white-label files
tsconfig.server.jsonserver.ts+ serverless.ts
angular.json- "browserTarget": "TopFans:build:production"+ "buildTarget": "TopFans:build:production"- "builder": "@nguniversal/builders:ssr-dev-server",+ "builder": "@angular-devkit/build-angular:ssr-dev-server",- "builder": "@nguniversal/builders:prerender",+ "builder": "@angular-devkit/build-angular:prerender",- "guessRoutes": false+ "discoverRoutes": falsepackage.json- "@nguniversal/builders": "~16.2.0",- "@nguniversal/express-engine": "~16.2.0",+ "@angular/ssr": "~17.3.17",

SSR engine

server.engine('html', ngExpressEngine({...}))new CommonEngine() Not a view engine any more. render() returns HTML, so one renderApp(req, res, next) helper replaces the three res.render('index') call sites, 404 fallback included.
'@nguniversal/express-engine/tokens''@core/express.tokens' REQUEST and RESPONSE are our own InjectionTokens now. NotFoundComponent imports from there.
renderModule from './src/main.server'from '@angular/platform-server' serverless.ts. inlineCriticalCss: true kept on both paths.
server.ts- import { ngExpressEngine } from '@nguniversal/express-engine';+ import { APP_BASE_HREF } from '@angular/common';+ import { CommonEngine } from '@angular/ssr';+ import { REQUEST, RESPONSE } from './src/app/core/express.tokens';- server.engine('html', ngExpressEngine({ bootstrap: AppServerModule, inlineCriticalCss: true }));+ const commonEngine = new CommonEngine();+ const renderApp = (req, res, next?) =>+   commonEngine.render({+     bootstrap: AppServerModule,+     documentFilePath: join(distFolder, 'index.html'),+     url: `${req.protocol}://${req.headers.host}${req.originalUrl}`,+     publicPath: distFolder,+     inlineCriticalCss: true,+     providers: [+       { provide: APP_BASE_HREF, useValue: req.baseUrl || '/' },+       { provide: REQUEST, useValue: req },+       { provide: RESPONSE, useValue: res }+     ]+   }).then((html) => res.send(html)).catch((err) => next?.(err));- return res.render('index', { req, providers: [{ provide: 'REQUEST', useValue: req }] });+ return renderApp(req, res);

Templates, router, libraries

@{{ data.get('fName').value }}&#64;{{ data.get('fName').value }} @ is reserved for control flow now. One occurrence, in post-price.component.html. The compiler found it.
return this.defaultSerializer.parse(url)try { parse(url) } catch { parse('/') } TrailingSlashUrlSerializer. Router 17 throws on malformed input the old one swallowed.
nothingbrowser-sync Dev dependency for the SSR dev server.
LibraryFromTo
primeng16.917.18, see next slides
@angular/fire717
@zxing/ngx-scanner317
ng-select1112
ngrx/store1617
ngx-bootstrap1112
ng-bootstrap1516
angular-fontawesome0.130.14
ngx-stripe1617

What broke: audio player

Symptom
Seeking does nothing; audio.currentTime becomes NaN. Card, button and slider styles off.
Cause
Material 17's slider is the MDC one. change is a plain DOM event with no .value, the seek input is input[matSliderThumb], and track clicks fire input, not change. ngx-audio-player still reads event.value.
Fix
AudioSliderPatchDirective on the player host: capture change and input, resolve the value from the thumb input, define .value on the event, set audio.currentTime itself. Selectors rewritten in six global scss files and nine component files.
audio-slider-patch.directive.ts- this.el.nativeElement.addEventListener('change', this.changeListener, true);+ this.el.nativeElement.addEventListener('change', this.sliderListener, true);+ this.el.nativeElement.addEventListener('input', this.sliderListener, true); + private resolveSliderValue(e: Event): number {+   const target = e.target as HTMLElement | null;+   if (!target?.closest?.('mat-slider, .mat-mdc-slider')) return NaN;+   if (target instanceof HTMLInputElement && Number.isFinite(target.valueAsNumber)) {+     return target.valueAsNumber;+   }+   const thumbInput = this.el.nativeElement.querySelector(+     'input[matSliderThumb], input.mdc-slider__input, input[type="range"]'+   ) as HTMLInputElement | null;+   return thumbInput?.valueAsNumber ?? NaN;+ }

What broke: custom multiselect

Symptom
Placeholder gone, selected label empty, duplicate options cannot be selected, parent control never marked touched or disabled.
Cause
PrimeNG 17 MultiSelect:
  • [defaultLabel] is [placeholder]
  • valuesAsString is label()
  • Selection compares by reference
Fix
Own the state. Internal FormControl<string[]>; every option is identified by a required dataKey in its config (sk or name today), so identity is explicit and one to one. Callers replace the options array instead of mutating it, and generated features get a unique name. Sync back to the parent and markAsDirty; onBlur and onPanelHide mark touched; statusChanges mirrors disabled. Six passes to land on this.
custom-multiselect.component.html- [defaultLabel]="field?.customMultiselectConfig?.defaultLabel"+ [placeholder]="field?.customMultiselectConfig?.defaultLabel"- {{ multiSelect.valuesAsString }}+ {{ multiSelect.label() }}+ (onBlur)="markParentControlAsTouched()"+ (onPanelHide)="markParentControlAsTouched()"custom-multiselect.component.ts+ selectionControl = new FormControl<string[]>([], { nonNullable: true });+ private optionByInternalId = new Map<string, unknown>();+ private internalIdByOption = new Map<unknown, string>(); + private getOptionIdentity(option: unknown): string | null {+   const value = option[this.field?.customMultiselectConfig?.dataKey];+   return value == null || value === '' ? null : String(value);+ }formBuilderInterface.ts, every consumer- dataKey?: string;+ dataKey: string;+ dataKey: 'sk'create-plan.component.ts- this.featuresField.options.push(newFeature);+ this.featuresField.options = [...this.featuresField.options, newFeature];

Angular's drop-in bundler swap: identical angular.json shape, identical dist/ layout, esbuild underneath. Still on Angular 17. Faster compiles, at the cost of two runtime crash fixes and one live stream fix.

What the config gave up

"vendorChunk": trueremoved esbuild does not split vendor code that way. Ignored with a warning.
"includePaths": ["sass"]["sass", "."] Webpack resolved bare scss imports from the project root implicitly. esbuild's sass loader does not.
bundleName: "lazy-flag-icons", inject: falseassets: css to /, svgs to /flags The stylesheet references /flags relatively and the new bundler did not copy the folder.
link.href = `/${bundle}.css`LAZY_CSS_LOADER_CONSTANTS.hrefs[bundle] Explicit href map. LazyCssBundle is now keyof typeof that map.
window.global.util = require('util')removed polyfills.ts. Dead shim.
angular.json- "builder": "@angular-devkit/build-angular:browser",+ "builder": "@angular-devkit/build-angular:browser-esbuild",- "vendorChunk": true,  "stylePreprocessorOptions": { "includePaths": [-   "sass"+   "sass",+   "."  ] }- { "input": "node_modules/flag-icon-css/css/flag-icons.min.css",-   "bundleName": "lazy-flag-icons", "inject": false }+ { "glob": "flag-icons.min.css", "input": "node_modules/flag-icon-css/css", "output": "/" },+ { "glob": "**/*", "input": "node_modules/flag-icon-css/flags", "output": "/flags" }lazy-css-loader.service.ts- link.href = `/${bundle}.css`;+ link.href = LAZY_CSS_LOADER_CONSTANTS.hrefs[bundle];

What broke: require() at runtime

Symptom
Build green. App crashes on load: require is not defined.
Cause
Webpack rewrote require() to __webpack_require__. esbuild emits ESM and leaves it alone.
  • TextEditorComponent: require('quill') in ngOnInit to keep Quill off the server
  • CommonService: require('../../../core/auth.service') to dodge a cycle, AuthService to CommonService and back
Fix
Static imports. Quill unwrapped with ?.default ?? QuillDefault. CommonService imports AuthService normally, and the cycle itself is gone: CircleBackgroundIconComponent and InfoIconComponent only wanted isBrowser, so they inject PLATFORM_ID instead of the service.
text-editor.component.ts- let Quill: any;- Quill = require('quill');- require('quill-emoji');+ import QuillDefault from 'quill';+ import 'quill-emoji';+ const Quill: any = (QuillDefault as any)?.default ?? QuillDefault;common.service.ts- const { AuthService } = require('../../../core/auth.service');+ import { AuthService } from '@core/auth.service';info-icon.component.ts- import { CommonService } from 'src/app/modules/shared/services/common.service';- constructor(private cs: CommonService) {}- if (this.cs.isBrowser) {+ constructor(@Inject(PLATFORM_ID) private readonly platformId: object) {}+ if (isPlatformBrowser(this.platformId)) {

What broke: live streaming

Symptom
Broadcasts fail to start. Found last, because nobody tested going live.
Cause
await import('amazon-ivs-web-broadcast') returns a different shape per bundler.
module.default = IVSBroadcastClientmodule.default = exports Under esbuild's CJS interop the class sits at module.default.default and the presets at module.default.*.
Fix
Detect the shape by a known preset, then unwrap .default ?? exports. Works under both bundlers.
active-livestream.component.ts- const IVSBroadcastClient = ivsModule?.default;+ const ivsExports = ivsModule?.default?.BASIC_FULL_HD_LANDSCAPE+   ? ivsModule.default+   : ivsModule;+ const IVSBroadcastClient = ivsExports?.default ?? ivsExports; - channelType === 'STANDARD' && ivsModule?.STANDARD_LANDSCAPE-   ? ivsModule.STANDARD_LANDSCAPE-   : ivsModule?.BASIC_FULL_HD_LANDSCAPE;+ channelType === 'STANDARD' && ivsExports?.STANDARD_LANDSCAPE+   ? ivsExports.STANDARD_LANDSCAPE+   : ivsExports?.BASIC_FULL_HD_LANDSCAPE;- this.bindIvsBroadcastNetworkListeners(ivsModule);+ this.bindIvsBroadcastNetworkListeners(ivsExports);

Browser, server and prerender used to be three architect targets with five environment configurations each. Now it is one build target, an ESM server, a new src/server-dom.ts, and a rewritten white-label generator.

angular.json: three targets become one

architect.server, serve-ssr, prerenderdeleted Five environment blocks each, near copies of the browser ones. About 130 lines.
"main": "src/main.ts""browser": "src/main.ts", "server": "src/main.server.ts", "ssr": { "entry": "server.ts" } One target knows all three entries.
"outputPath": "dist/TopFans/browser""dist/TopFans" The builder adds browser/ and server/ itself.
"serviceWorker": true, "ngswConfigPath": "...""serviceWorker": "ngsw-config.json"
"polyfills": "src/polyfills.ts"["src/polyfills.ts"]
commonChunk, buildOptimizerremoved The application builder has neither.
nothing"externalDependencies": ["domino", "express", "compression", "helmet"] Kept out of the server bundle. Per configuration: prerender.routesFile and discoverRoutes: false.
angular.json- "builder": "@angular-devkit/build-angular:browser-esbuild",+ "builder": "@angular-devkit/build-angular:application",- "outputPath": "dist/TopFans/browser",- "main": "src/main.ts",- "polyfills": "src/polyfills.ts",- "ngswConfigPath": "ngsw-config.json"+ "outputPath": "dist/TopFans",+ "browser": "src/main.ts",+ "server": "src/main.server.ts",+ "ssr": { "entry": "server.ts" },+ "polyfills": ["src/polyfills.ts"],+ "prerender": false,+ "externalDependencies": ["domino", "express", "compression", "helmet"]configurations.production- "serviceWorker": true,- "commonChunk": true,- "buildOptimizer": true,+ "serviceWorker": "ngsw-config.json",+ "prerender": { "routesFile": "prerender.routes.txt", "discoverRoutes": false }

server.ts becomes an ES module

__non_webpack_require__.main.filename === __filenameisMainModule(import.meta.url) The bundle is server.mjs. Compares against process.argv[1]; the webpack-only global is gone.
require('url').URL, 'path''node:url', 'node:path'
(global as any).window = domino.createWindow(...) and friendsinstallServerDom(template) New src/server-dom.ts: window, document, navigator, localStorage, location, IntersectionObserver. Set with defineProperty and tolerant of read-only globals on Node 21+. localStorage is a Map-backed Storage.
main.server.ts exports AppServerModule+ zone.js/node, ./server-dom, @angular/localize/init; default export
tsconfig.app.json: src/polyfills.ts+ main.server.ts, server-dom.ts, server.ts One compilation for browser and server.
server.ts- import domino from 'domino';- import { join } from 'path';+ import { installServerDom } from './src/server-dom';+ import { join } from 'node:path';+ import { fileURLToPath, pathToFileURL, URL as NodeURL } from 'node:url'; - const window = domino.createWindow(template);- (global as any).window = window;- (global as any).document = window.document;- (global as any).localStorage = window.localStorage;- (global as any).IntersectionObserver = class { observe() {} ... };+ installServerDom(template); - declare const __non_webpack_require__: NodeRequire;- if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {+ if (isMainModule(import.meta.url) || process.argv[1]?.includes('iisnode')) {src/main.server.ts+ import 'zone.js/node';+ import './server-dom';+ import '@angular/localize/init';+ export { AppServerModule as default } from './app/app.server.module';

npm scripts and the white-label generator

Scripts

ng run TopFans:server, ng run TopFans:prerender:<env>ng build Prerender is a flag. Plain builds pass --prerender=false; prerender scripts set PRERENDER=true and let the configuration's block run.
node dist/TopFans/server/main.jsnode dist/TopFans/server/server.mjs serve:ssr
ng run TopFans:serve-ssrng serve dev:ssr. --max_old_space_size=10048 unchanged everywhere.

scripts/generate-whitelabel.js, per tenant

clone architect as isdelete architect.server, architect['serve-ssr']
TopFans:build:<env><user>:build:<env> Rewrites buildTarget, browserTarget, serverTarget wherever they appear.
prerender.routes.txtprerender-<user>-<env>.routes.txt Per project prerender.routesFile.
skip if script existsreplace if it still contains :prerender: Existing tenant scripts upgrade in place.

What broke: prerender found SSR bugs

Symptom
Public pages fail to prerender with an unhandled Unauthorized.
Cause
Prerender runs every route on Node with no session and no network. ErrorInterceptor threw on 401 and let status 0 propagate.
Fix
Return EMPTY for 401, and for status 0 on the server. The request completes quietly.
Symptom
getBoundingClientRect is not a function during prerender.
Cause
PinnedHorizontalScrollLayoutComponent guarded with typeof window === 'undefined'. domino provides a window, so the guard passed.
Fix
isPlatformBrowser(PLATFORM_ID), plus a check that getBoundingClientRect is a function.
error.interceptor.ts- import { BehaviorSubject, Observable, of, throwError } from 'rxjs';+ import { BehaviorSubject, EMPTY, Observable, of, throwError } from 'rxjs';+ if (!this.cs.isBrowser && err.status === Application.ZERO) {+   return EMPTY;+ }- return throwError(() => new Error('Unauthorized'));+ return EMPTY;pinned-horizontal-scroll-layout.component.ts- if (typeof window === 'undefined' || this.railMotionRafId !== null) {+ if (!this.isBrowser || this.railMotionRafId !== null) {- if (!wrapper) {+ if (!this.isBrowser || !wrapper || typeof wrapper.getBoundingClientRect !== 'function') {

A quiet step. Seven files, no code migrations, TypeScript unchanged. The builder work in 17 paid for this one. NgModules and PrimeNG 17.18 kept on purpose.

What changed

"polyfills": ["src/polyfills.ts"]+ "@angular/localize/init" 18 no longer pulls the $localize runtime in implicitly.
join(distFolder, 'index.html')index.csr.html when present The 18 builder writes the client shell there and reserves index.html for prerendered output. Rendering from the wrong one double-bootstraps.
import { IConfig } from 'ngx-mask'removed No longer exported. The options constant that used it in WalletModule was unused.
[(ngModel)] + [ngModelOptions]="{ standalone: true }"removed create-plan.component.html. It sat on top of a reactive control that already owned the value.
LibraryFromTo
@angular/ssr17.318.2
@angular/material17.318.2
@angular/fire1718
ngrx/store1718
ng-select1213
ng-bootstrap1617
ngx-bootstrap1218
ngx-toastr1419
ngx-mask1618
ngx-stripe1718
@zxing/ngx-scanner1718
angular-eslint17.518.4

What broke

Symptom
DOMPurify.sanitize is not a function on the server path.
Cause
AppComponent does import DOMPurify from 'dompurify'. The package is CJS with module.exports = DOMPurify; in the 18 server bundle the default import resolved to the namespace object. Same class of bug as Quill and IVS, one version later.
Fix
Namespace import, take .default ?? ns, give it a narrow local type. Third time a CJS default import bit us; it became a rule.
src/app/app.component.ts- import DOMPurify from 'dompurify';+ import * as DOMPurifyNs from 'dompurify'; + type DomPurifySanitize = {+   sanitize: (dirty: string, cfg: { ALLOWED_TAGS: string[]; ALLOWED_ATTR: string[] }) => string;+ }; + const DOMPurify: DomPurifySanitize =+   (DOMPurifyNs as { default?: DomPurifySanitize }).default ??+   (DOMPurifyNs as DomPurifySanitize);

The standalone default flips. 704 files touched, nearly all of them one line: standalone: false. Still NgModules, still PrimeNG 17, still the application builder.

What changed

@Component({ selector, templateUrl })@Component({ ..., standalone: false }) 19 defaults standalone to true. The migration writes the flag on every NgModule-declared class: 490 components, directives and pipes. The 166 that already said standalone: true lost the line.
import { CommonEngine } from '@angular/ssr'from '@angular/ssr/node' Node-specific entry point split out of the main package.
new CommonEngine()new CommonEngine({ allowedHosts }) 19.2 validates the Host header on SSR and on ng serve (security.allowedHosts in angular.json). The list is built from environment.websiteURL, webURL, original_web_url, plus *.topfan.dev, *.topfan.com and localhost.
start:hmr, node:start:hmr, dev:hmr, dev:ssrstart:no-hmr ng serve has HMR on by default in 19 and already serves SSR. The opt-out script is the only one left.
TypeScript 5.4.5, zone.js 0.14, typescript-eslint 75.8.3, 0.15, 8
LibraryFromTo
@angular/material, cdk18.219.2
@angular/fire1819
angular-fontawesome0.151.0
ng-bootstrap1718
ng-select1314
ngrx/store1819
ngx-bootstrap1819
ngx-device-detector89
ngx-infinite-scroll1819
ngx-mask1819
ngx-owl-carousel-o1819
@zxing/ngx-scanner1819
angular-eslint18.419.8

Follow-ups on 19

nodejs: 18 in buildspec-*.yml, "node": "18" in functions20 CI and Cloud Functions had stayed on 18 while the app moved to 20.19.6. @types/node 18 to 20 with it.
"lib": ["es2018", "dom"]["ES2022", "dom"] tsconfig.json. Matches what Angular 19 targets.
ngsw-config: /common.*.js, !/runtime.*.js+ /polyfills.js, /chunk-*.js, /*.css The service worker manifest learns the application builder's file names.
buildspec-s3.yml (and three more)-       nodejs: 18+       nodejs: 20functions/package.json-   "node": "18"+   "node": "20"package.json-   "@types/node": "^18.19.120",+   "@types/node": "^20.19.6",tsconfig.json-       "es2018",+       "ES2022",ngsw-config.json+   "/polyfills.js",+   "/chunk-*.js"+   "/*.css",+   "!/chunk-*.js",+   "!/styles.*.css"

What to watch

Risk
A request whose Host header is not in allowedHosts is rejected by CommonEngine.
Why
The list is derived from the tenant's environment file. New white-label sites get their hosts from the generator; a hostname added by hand anywhere else does not.
Rule
Every hostname a tenant serves must be in its environment.* URLs. Nothing else feeds the allow list.
Risk
A new component written the old way, no standalone flag, declared in an NgModule, fails to compile.
Why
It is standalone by default now, and standalone classes cannot be declared in a module.
Rule
Either write standalone: false and declare it, or leave the flag off and import it where it is used. Not both.

Nothing broke on this step. The bundler and SSR work in 17 carried the risk.

server.ts- import { CommonEngine } from '@angular/ssr';+ import { CommonEngine } from '@angular/ssr/node';+ import { environment } from './src/environments/environment'; + function getSsrAllowedHosts(): string[] {+   const hosts = new Set(['localhost', '127.0.0.1', '::1', '*.topfan.dev', '*.topfan.com']);+   for (const value of [environment.websiteURL, environment.webURL, environment.original_web_url]) {+     const host = hostnameFromUrl(value);+     if (host) hosts.add(host);+   }+   return [...hosts];+ } - const commonEngine = new CommonEngine();+ const commonEngine = new CommonEngine({ allowedHosts: getSsrAllowedHosts() });any declared component, directive or pipe  @Component({    selector: 'app-root',    templateUrl: './app.component.html',+   standalone: false  })

Twelve vendor stylesheets rode inside the global bundle on every page. Not an Angular change; the application builder made it cheap to do. Three passes: split them out, load each from the widget that draws it, make it survive SSR.

Three passes

Split
Named bundles
  • 12 entries in styles become bundleName + inject: false
  • index.html links all twelve
  • AppComponent calls load() for all 15 as a fallback
  • Service dedupes against existing <link>s
Defer
Load from the widget
  • index.html links and the AppComponent call removed
  • LazyVendorCssDirective matches vendor tags and classes
  • Players, dialogs, editor, toaster call loadFor*() in their constructors
Server
Render the link on SSR
  • isPlatformBrowser guard dropped from load()
  • The <link> is appended to the server document and serializes into the HTML

End state on 19: first paint carries only our own scss and the white-label theme. A vendor sheet arrives the moment its first widget is constructed, on the server or in the browser, and never twice. Fifteen bundles then; twelve now. PrimeNG 20 has no theme CSS, and ng-select's sheet went back into styles for ordering. Next page.

Split the bundle

"styles": ["node_modules/.../theme.css", ...]{ input, bundleName: "lazy-*", inject: false } intl-tel-input, Material indigo-pink, ng-select, bs-datepicker, toastr, owl (2), quill (2), PrimeNG (3). Each becomes its own file in dist/, cached on its own.
3 bundles: videojs, emoji-mart, flag-icons15 bundles The three that were already lazy keep their names. Same LAZY_CSS_LOADER_CONSTANTS.hrefs map, twelve more keys.
link.href = hrefs[bundle]; appendskip if a <link> with that href exists Checked with and without the leading slash, because index.html wrote them relative and the map writes them absolute.
ngsw: /common.*.js, !/runtime.*.js+ /polyfills.js, /chunk-*.js, /*.css Service worker manifest learns the application builder's names, and the split sheets are prefetched.
angular.json, styles- "node_modules/primeng/resources/themes/saga-blue/theme.css",- "node_modules/primeng/resources/primeng.min.css",- "node_modules/primeicons/primeicons.css",+ {+   "input": "node_modules/primeng/resources/themes/saga-blue/theme.css",+   "bundleName": "lazy-primeng-theme",+   "inject": false+ },+ { ..., "bundleName": "lazy-primeng", "inject": false },+ { ..., "bundleName": "lazy-primeicons", "inject": false },lazy-css-loader.constants.ts+ 'lazy-primeng-theme': '/lazy-primeng-theme.css',+ 'lazy-primeng': '/lazy-primeng.css',+ 'lazy-primeicons': '/lazy-primeicons.css',lazy-css-loader.service.ts+ const hrefNoSlash = href.startsWith('/') ? href.slice(1) : href;+ if (this.document.querySelector(`link[href="${href}"]`) ||+     this.document.querySelector(`link[href="${hrefNoSlash}"]`)) {+   return;+ }

Load from the widget that needs it

TriggerBundles
owl-carousel-oowl-carousel, owl-theme
ng-selectng-select
ngx-intl-tel-inputintl-tel-input, flag-icons
emoji-martemoji-mart
quill-editor, .ql-editor, TextEditorquill-snow, quill-emoji
p-carousel, p-multiSelect, p-checkbox, .pi, CustomMultiselectprimeng-theme, primeng, primeicons
[bsDatepicker], DateRangeFilterbs-datepicker
.flag-iconflag-icons
ToasterServicetoastr
VideoPlayer v1, v2, v3, modal, IvsPlayerServicevideojs
SendTip, PostLikes, SharedDialog, CommentsSheet, ReelsMobileView, ImageOverlaymaterial-theme

Tags and classes go through the directive, declared in SharedModule and DynamicFormBuilderModule, imported by the standalone AlertModal and CustomMultiselect. Things with no template hook, players, dialogs, the toaster, call the service directly.

lazy-vendor-css.directive.ts+ @Directive({+   selector: LAZY_CSS_LOADER_CONSTANTS.directiveSelector,+   standalone: true+ })+ export class LazyVendorCssDirective {+   constructor(el: ElementRef<HTMLElement>, loader: LazyCssLoaderService) {+     const tag = el.nativeElement.tagName.toLowerCase();+     if (tag === 'owl-carousel-o') { loader.loadForCarousel(); return; }+     ...+   }+ }lazy-css-loader.constants.ts+ directiveSelector: 'owl-carousel-o, ng-select, ngx-intl-tel-input, emoji-mart,+   quill-editor, [bsDatepicker], p-carousel, p-multiSelect, p-multiselect,+   p-checkbox, .pi, .flag-icon, .ql-editor',app.component.ts- this.lazyCssLoader.load('lazy-intl-tel-input', 'lazy-material-theme', ... 15 bundles);src/index.html- <link rel="stylesheet" href="lazy-primeng-theme.css">- ... 11 more

What broke

Symptom
Server-rendered pages arrived with widgets unstyled. They snapped into shape once the client bootstrapped.
Cause
load() returned early when !isPlatformBrowser. The <link> never reached the server document, so the HTML shipped without it.
Fix
Drop the guard. DOCUMENT is the server DOM during render; a link appended there serializes into the page, and the browser sees it before any script runs.
Symptom
Multiselect overlay panel rendered with no PrimeNG styles.
Cause
Templates write <p-multiSelect>. The directive selector said p-multiselect. Angular matches element names as written.
Fix
Both spellings in the selector, and CustomMultiselect calls loadForPrimeNG() itself so the overlay does not depend on the match.
Symptom
Text editor's undo and redo toolbar buttons lost their override styles.
Cause
quill.snow.css used to sit in styles.css, before component styles. Now it arrives after them. Equal specificity, last one wins.
Fix
button.ql-undo instead of .ql-undo. One step up in specificity, order no longer matters.
lazy-css-loader.service.ts- import { Injectable, PLATFORM_ID, inject } from '@angular/core';- import { isPlatformBrowser, DOCUMENT } from '@angular/common';+ import { Injectable, inject } from '@angular/core';+ import { DOCUMENT } from '@angular/common'; -   private platformId = inject(PLATFORM_ID);    load(...bundles: LazyCssBundle[]): void {-     if (!isPlatformBrowser(this.platformId)) return;text-editor.component.scss- .ql-undo,- .ql-redo {+ button.ql-undo,+ button.ql-redo {

The LTS stop. Angular itself was quiet: one import moved, one tsconfig flag. PrimeNG went 17 to 20 in the same step because 20 is the first major that dropped the CSS theme files we had just made lazy. That is where the work was.

What changed

import { DOCUMENT } from '@angular/common'from '@angular/core' Schematic. 79 import lines across services, components and the lazy CSS loader.
"moduleResolution": "node""bundler" tsconfig.json. Matches how esbuild already resolved packages; TypeScript now agrees with the build.
(DOMPurifyNs as DomPurifySanitize)(DOMPurifyNs as unknown as DomPurifySanitize) Stricter types under bundler resolution. The interop pattern from 17 stays; only the cast widened.
ng generate component foo → foo.component.tssame, pinned in angular.json 20 drops the .component suffix by default. A schematics block keeps the old names so new files match the 700 existing ones.
primeng 17.18, theme.css + primeng.min.cssprimeng 20.4, providePrimeNG({ theme: { preset: Lara } }) Design-token theming from @primeuix/themes, injected at runtime. There is no stylesheet to bundle, and the preset is not cosmetic: base styles size every widget through its dt() tokens. Next slide.
LibraryFromTo
@angular/material, cdk19.220.2
@angular/fire1920
angular-fontawesome1.02.0
ng-bootstrap1819
ng-select1420.4.4
ngrx/store1920
ngx-bootstrap1920
ngx-device-detector910
ngx-infinite-scroll, ngx-mask, ngx-owl-carousel-o1920
@zxing/ngx-scanner1920
primeng17.1820.4
@primeuix/themes-1.2
angular-eslint19.819.8, held

PrimeNG 17 to 20

17 class20 class
.p-multiselect-panel.p-multiselect-overlay
.p-multiselect-items-wrapper / -items / -item.p-multiselect-list-container / -list / -option
.p-multiselect-item.p-highlight.p-multiselect-option-selected
.p-checkbox.p-highlight.p-checkbox-checked .p-checkbox-box
.p-multiselect-token / -token-label / -token-icon.p-multiselect-chip-item / -chip / -chip-icon
.p-multiselect-trigger.p-multiselect-dropdown
.p-carousel-prev / -next.p-carousel-prev-button / -next-button

Renamed in both theme files, styles.scss, _page.scss, the custom multiselect, and seven feature scss files that reach into the overlay. lazy-primeng-theme and lazy-primeng deleted; loadForPrimeNG() fetches primeicons only. Lara supplies the dimensions and the light palette; colours and shapes stay in our scss.

app.module.ts+ import { providePrimeNG } from 'primeng/config';+ import * as LaraTheme from '@primeuix/themes/lara';  providers: [+   providePrimeNG({ theme: { preset: LaraTheme.default, options: { darkModeSelector: 'none' } } }),angular.json- { "input": ".../saga-blue/theme.css", "bundleName": "lazy-primeng-theme", ... },- { "input": ".../primeng.min.css", "bundleName": "lazy-primeng", ... },lazy-css-loader.service.ts-   this.load('lazy-primeng-theme', 'lazy-primeng', 'lazy-primeicons');+   this.load('lazy-primeicons');custom-multiselect.component.html- <span class="p-multiselect-token-label">{{ item[optionLabel] }}</span>- <TimesCircleIcon class="p-multiselect-token-icon"></TimesCircleIcon>+ <span class="p-multiselect-chip">{{ item[optionLabel] }}</span>+ <svg data-p-icon="times-circle" class="p-multiselect-chip-icon"></svg>

What broke

Symptom
Custom multiselect opened with an unstyled search box and select-all row, and an empty strip above them.
Cause
On 17 our pTemplate="header" rendered inside .p-multiselect-header, and every override hung off that class. On 20 the header template renders before that div, and the div itself still renders, empty, because showHeader defaults to true.
Fix
[showHeader]="false". Our search and select-all get their own .custom-multiselect-header wrapper, the footer its own .custom-multiselect-footer, and the scss hooks move onto classes we own. Lara's 1px overlay border, which saga never drew, cleared.
Symptom
Selected options and the select-all box lost their theme colour on white-label sites.
Cause
Overrides targeted .p-highlight. 20 marks a checked box with .p-checkbox-checked on the wrapper and colours the inner .p-checkbox-box.
Fix
Rewrite the selector chain in both theme files. --configThemeColor lands on the box, not the option.
Symptom
ng-select dropdown painted white on the dark theme, and spaces vanished from selected labels.
Cause
ng-select 20's default.theme.css was still a lazy bundle. It now arrived after our overrides and won on order. A leftover display: flex on .ng-value-label collapsed whitespace.
Fix
Its sheet goes back into the global styles array, ahead of styles.scss. The lazy-ng-select bundle, loadForNgSelect() and the flex rule are deleted. The one vendor sheet that must precede our css is not a lazy one. Pinned to 20.4.4: 20.4.0 also dropped async ng-option lists.
Symptom
Carousel arrows rendered as square Lara buttons and went dark with the OS. Owl arrows lost their round background.
Cause
20 nests a .p-button inside .p-carousel-prev-button; we styled the old outer class. Lara's darkModeSelector defaults to system. The owl rule used [class*='owl-'] and matched too much.
Fix
Full arrow styles on wrapper and nested button in styles.scss and the white-label theme. Owl selectors narrowed to .owl-prev / .owl-next. darkModeSelector: 'none' pins the light palette.

21 is the LTS we stop on. Support runs through June 2027. Angular asked for one provider and TypeScript 5.9. The real work was dropping AngularFire and surviving ngx-bootstrap 21. NgModules and zone.js stay.

Same rule as every earlier bump

One change at a time. Firebase moved while we were still on 20, so a broken push token could not be blamed on the framework.

Still on 20
Firebase SDK, no AngularFire
  • Four SDK functions we already used
  • Fourteen callers unchanged
Framework
20 to 21
  • Core, CLI, Material, TypeScript 5.9
  • provideZoneChangeDetection()
Libraries
The ones that reject 21
  • PrimeNG, ngx-bootstrap, ng-select, ngrx
  • Font Awesome 7 with it

ng update needed --force. ng-ivs still peers Angular 12, the audio player still peers Material 17, ngx-stripe still peers Angular 18. They already ran that way on 20. The flag skips the peer check, not the migrations.

What Angular itself changed

zone scheduler, implicitprovideZoneChangeDetection() 21 no longer starts it for you. Without this line the app paints once and never updates. It lives in AppModule, so AppServerModule gets it too.
TypeScript 5.8.35.9.3 Required. The voice recorder buffer had to become Uint8Array<ArrayBuffer> before getByteFrequencyData would type-check.
@HostListener('window:scroll', ['$event'])@HostListener('window:scroll') 21 type-checks host bindings. 49 handlers took no parameter. The event was unused, so we stopped passing it.
control-flow schematic, 659 templatesreverted, still *ngIf ng update rewrote them to @if. Four templates broke. We put them back. That migration is its own project.
LibraryFromTo
@angular/core, common, …20.321.2.23
CLI, SSR, build-angular20.321.2.24
Material, CDK20.221.2.14
primeng20.421.1.10
ngx-bootstrap20.021.2.2
ng-select20.421.8.2
ngrx/store20.121.1.1
angular-fontawesome2.04.0, svg-core 7.3
angular-eslint19.821.4
@angular/fire20.0removed

Firebase without AngularFire

AngularFire's only 21 build is a November 2025 RC that pulls Firebase 12. We use four SDK functions. Calling them ourselves keeps Firebase at 11.10.0, the version already installed.

The public FirebaseService API did not change, so its 14 callers did not either. The service worker never depended on AngularFire.

provideFirebaseApp, provideMessaging, provideRemoteConfigremoved from AppModule Init moved into the service. One getApps()[0] ?? initializeApp(config) so white-label builds share the same code and swap config via fileReplacements.
onMessage only when the token changedonMessage every session A returning user with a saved token never got a foreground listener. Register the token when it changes. Attach the listener either way.
SDK callbacks, unzonedNgZone.run() for UI state AngularFire did this for us. Remote Config writes iosPayButtonsConfig inside the zone so the view updates.
firebase.service.ts+ import { getApps, initializeApp } from 'firebase/app';+ import { getMessaging, getToken, onMessage, isSupported as isMessagingSupported } from 'firebase/messaging';+ import { getRemoteConfig, fetchAndActivate, getValue, isSupported as isRemoteConfigSupported } from 'firebase/remote-config'; + private async getMessagingInstance() {+   if (!this.platformService.isBrowser) return null;+   if (!(await isMessagingSupported())) return null;+   this.messaging = getMessaging(this.getFirebaseApp());+   return this.messaging;+ }registerWebToken+ const token = await getToken(messaging, { vapidKey: environment.firebaseVapidKey });+ this.setConfigToken(token, false);+ this.onMessage();getRemoteConfigValue+ this.zone.run(() => { this.iosPayButtonsConfig = parsed; });

ngx-bootstrap 21

Module.forRoot() is gone. Every module is imported bare. BsDropdownDirective.isOpen is a getter now, so code that assigned it calls toggle() or hide() instead.

ModalModule.forRoot()ModalModule Same for dropdown, datepicker, tabs, accordion, typeahead. App-wide, mechanical.
dropdown.isOpen = falsedropdown.hide() or toggle() Home discover, forum, collaborators, post-detail, post-submit. Assigning the getter does not compile.
ngx-intl-tel-input calls BsDropdownModule.forRoot()patched to import the module The package is still 15 and has no 21 release. patch-package rewrites three build outputs. postinstall applies it.
shared.module.ts, and every feature module- ModalModule.forRoot(),- BsDropdownModule.forRoot(),+ ModalModule,+ BsDropdownModule,home.component.ts- dropdown.isOpen = false;+ dropdown.toggle();patches/ngx-intl-tel-input+15.0.0.patch- export const dropdownModuleForRoot = BsDropdownModule.forRoot();+ export const dropdownModuleForRoot = BsDropdownModule;

What broke

Symptom
The build failed on host listeners. Handlers that take no arguments were being passed $event.
Cause
21 type-checks host bindings even with strictTemplates off. 49 window:scroll and window:resize listeners were written that way, mostly infinite-scroll lists.
Fix
Drop the ['$event'] args list. Behaviour is the same. The listeners still fire. We did not turn typeCheckHostBindings off.
Symptom
Signup's country dropdown died with forRoot is not a function.
Cause
ngx-intl-tel-input 15 still calls BsDropdownModule.forRoot() inside its own module. ngx-bootstrap 21 removed that method.
Fix
A three-file patch that imports the module directly. If the phone flags ever look unstyled, the lazy vendor CSS for intl-tel-input did not load. That path did not change.

Found after the merge

Symptom
Tag a user in a new post, press close. The modal stays.
Cause
DashboardModule listed BsModalService in its providers, so the tag dialog got a second instance. It called hide() on a service that had never opened anything.
Fix
Inject BsModalRef and hide that. Drop the extra provider. ngx-bootstrap 20 tolerated the duplicate; 21 did not.
Symptom
Open Messages. The tab is highlighted, the pane is empty.
Cause
ngx-bootstrap 21 fires selectTab on init with the heading as rendered, "Messages". Our active check compared it to the enum value for the small-screen variant and lost.
Fix
Map the small-screen heading back to MESSAGES before the comparison, and accept either in the template.
Symptom
Livestream preview opened the webcam and never let go. Test Video showed an error instead of the camera.
Cause
Four things. The permission check kept a track open. Two handlers wrote the webcam control. The modal started before it had the device ID. Close went through the global BsModalService, the same mismatch as the tag dialog.
Fix
Stop the permission stream once the device list loads. Let formControlName own the value. Set the ID, then startCamera(). Close with BsModalRef. Only the last one is a 21 bug; the rest were older and hidden behind it.
Symptom
NG8113 warnings in the build log, six standalone components.
Cause
Since 19 the compiler reports imports a standalone template never uses. CurrencyPipe, InfoModalComponent, LanguageSwitcherComponent, two directives. Copied in, never bound.
Fix
Removed them. A warning today, and the kind that turns into an error at the next major.

How the phone-input patch survives npm install

node_modules is a cache. Delete it and you get what the author published, which for ngx-intl-tel-input 15 still calls BsDropdownModule.forRoot(). Git stores our edit as a diff. postinstall replays it. Skip either half and the build is red again.

1
Unpack
  • Stock 15.0.0 lands from the registry
  • The broken forRoot() call is back
2
Hook
  • npm sees "postinstall": "patch-package"
  • Runs it once, after every package is on disk
3
Match and apply
  • Reads patches/ngx-intl-tel-input+15.0.0.patch
  • Name pins the package and the version
4
Build
  • NgxIntlTelInputModule now loads the patched copy
  • Three built files, one line each
NameWhat it does here
postinstallnpm lifecycle hook in package.json. Ours runs one command.
patch-packageCLI in devDependencies. Applies every diff under patches/ to node_modules.
hasInstallScriptLockfile flag npm sets on any package with an install hook. Ours, esbuild, protobufjs. It means "runs a script", not "malware".

Bumping the library breaks the patch on purpose: the filename no longer matches, install fails, someone has to look. To remake it, edit the files under node_modules/ngx-intl-tel-input until ng build passes, then npx patch-package ngx-intl-tel-input and commit the new file.

What we do differently now

The version pages say what changed. This page says what we write from here on, and what 21 has not changed yet. Every rule traces back to a specific breakage.

Module shape
Server code
Build config
Framework APIs
After 21
Next upgrade
What remains

Module shape and imports

RuleTraced to
No require() in application code. The bundle is ESM; require is undefined at runtime and the build will not tell you.Quill, CommonService
CJS packages get one interop line at the import site: (mod as any).default ?? mod. Never dot into .default anywhere else.Quill, IVS, DOMPurify
Dynamic import() of a CJS package: detect the shape by a known export before unwrapping.IVS
Import cycles get removed, not hidden behind Injector.get and a lazy path. A leaf component that wants one boolean gets the primitive, not the fat service.CommonService, InfoIcon, CircleBackgroundIcon
Vendor CSS never goes in the global styles array or index.html. It is a bundleName + inject: false entry, a key in LAZY_CSS_LOADER_CONSTANTS.hrefs, and a loadFor*() call from the widget that renders it. One exception: a sheet our own scss overrides by order, not specificity, stays in styles ahead of styles.scss.12 bundles, 0 on first paint; ng-select is the exception
Overrides for a third-party component hang off a class we own, never the library's internal class. Wrap the slot and style the wrapper.PrimeNG 20 renamed every multiselect and carousel class
the one interop pattern+ import * as ModNs from 'some-cjs-package';+ const Mod = (ModNs as { default?: typeof ModNs }).default ?? ModNs; dynamic import of CJS+ const m = await import('some-cjs-package');+ const exportsObj = m?.default?.KNOWN_EXPORT ? m.default : m;+ const Client = exportsObj?.default ?? exportsObj; never- const { Thing } = require('../../path/to/thing');- const svc = this.injector.get(require('./svc').Svc);

Code that runs on the server

RuleTraced to
isPlatformBrowser(PLATFORM_ID), never typeof window. domino gives the server a window, so the typeof check lies.pinned scroll layout
Layout and measurement APIs are browser-only even when window exists. Guard by platform, then by feature.getBoundingClientRect
Interceptors return EMPTY for a missing session or network on the server. Prerender has neither.ErrorInterceptor
New browser globals go in src/server-dom.ts via setGlobal. Nothing gets assigned onto global in server.ts.server-dom.ts
Run npm run prerender-qa before merging anything that touches a public route. The dev server never exercises this path.both prerender bugs
platform guard- if (typeof window === 'undefined') return;+ constructor(@Inject(PLATFORM_ID) private readonly platformId: object) {}+ private get isBrowser() { return isPlatformBrowser(this.platformId); }+ if (!this.isBrowser) return; feature guard on top+ if (!this.isBrowser || typeof el.getBoundingClientRect !== 'function') return; server-side http failure- return throwError(() => new Error('Unauthorized'));+ return EMPTY;

Build configuration

RuleNote
One build target per project. No server, serve-ssr or prerender targets.The generator deletes them anyway.
These do not exist on the application builder and are silently ignored: vendorChunk, commonChunk, buildOptimizer, ngswConfigPath, main.Use browser, polyfills: [], serviceWorker: "<path>".
Prerender is a build flag. --prerender=false for plain builds, PRERENDER=true for prerender scripts.No prerender target to run.
Server-only packages go in externalDependencies.domino, express, compression, helmet
White-label projects are generated, never hand-edited. Change scripts/generate-whitelabel.js and regenerate.Edits get overwritten.
Node is pinned in .nvmrc, Dockerfile, the four buildspec-*.yml and functions/package.json. They move together.CI sat on 18 for a while after the app moved.
Every hostname a tenant serves lives in its environment.* URLs. That is the only source for SSR allowedHosts.Unknown Host is rejected.
angular.json, a new project's build target+ "builder": "@angular-devkit/build-angular:application",+ "options": {+   "outputPath": "dist/<project>",+   "browser": "src/main.ts",+   "server": "src/main.server.ts",+   "ssr": { "entry": "server.ts" },+   "polyfills": ["src/polyfills.ts", "@angular/localize/init"],+   "prerender": false,+   "externalDependencies": ["domino", "express", "compression", "helmet"]+ } - "vendorChunk": true,- "commonChunk": true,- "buildOptimizer": true,- "ngswConfigPath": "ngsw-config.json"

Framework and library APIs

@Component({ ... }) declared in an NgModule@Component({ ..., standalone: false }) Standalone is the default since 19. Declared classes say so explicitly.
class FooGuard implements CanActivateclass FooGuard, or a CanActivateFn Existing class guards stay as they are.
TransferState from '@angular/platform-browser'from '@angular/core'
DOCUMENT from '@angular/common'from '@angular/core' Since 20. The common export still exists, deprecated.
@{{ handle }}&#64;{{ handle }} In templates, @ means control flow.
[(ngModel)] on a reactive controlformControlName
NgxMaskModule.forRoot()NgxMaskDirective, NgxMaskPipe, provideNgxMask()
p-multiSelect [defaultLabel][placeholder]
multiSelect.valuesAsStringmultiSelect.label()
multiSelect.removeChip()multiSelect.removeOption() Selection is by reference. Every custom multiselect config sets dataKey, and option arrays are replaced, never mutated.
<TimesCircleIcon>, primeng/icons/*<svg data-p-icon="times-circle"> PrimeNG 20 icons are attribute-driven. No component import per icon.
theme.css in stylesprovidePrimeNG({ theme: { preset } }) One preset, set once in AppModule, and it stays: PrimeNG sizes widgets through its dt() tokens. Our scss overrides colour and shape through wrapper classes we own.
(change)="seek($event.value)" on mat-sliderread input[matSliderThumb].valueAsNumber; listen to input too MatSliderChange is gone in Material 17+.

After 21: HTTP without the module

21 deprecates HttpClientModule. Not removed, but every ng update from here will nag about it, and the replacement is smaller. provideHttpClient() in the AppModule providers, two class interceptors become two functions.

RuleTraced to
HTTP is provided once, in AppModule, with provideHttpClient(withFetch(), withInterceptors([...])). No component imports HttpClientModule.FormSelectComponent had its own copy
Interceptors are HttpInterceptorFn. Dependencies come from inject() at the top of the function, never from a constructor.ErrorInterceptor, AuthInterceptor
Array order is run order. errorInterceptor first so it sees the response before auth logs anyone out.the old HTTP_INTERCEPTORS multi providers, same order
withFetch() is the SSR transport. The skipInterceptor=true bypass and the server-side EMPTY return stay exactly where they were.prerender, 401 on the server
app.module.ts-   HttpClientModule,-   { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },-   { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },+   provideHttpClient(+     withFetch(),+     withInterceptors([errorInterceptor, authInterceptor])+   ),auth-int.interceptor.ts- @Injectable()- export class AuthInterceptor implements HttpInterceptor {-   constructor(private readonly authService: AuthService, ...) {}-   intercept(req: HttpRequest<any>, next: HttpHandler) {+ export const authInterceptor: HttpInterceptorFn = (req, next) => {+   const authService = inject(AuthService);+   const cs = inject(CommonService);    if (req.urlWithParams.includes('skipInterceptor=true')) {-     return next.handle(req);+     return next(req);    }

After 21: Sass without @import

Angular 21 ships Dart Sass 1.97, which warns on every @import and says 3.0 will reject it. 110-plus component sheets did that, plus the globals. A silence list in angular.json was tried and taken out. Bootstrap keeps its own @import; not our file.

RuleTraced to
A component sheet loads shared names with one line, @use 'tools' as *;. Never @import, never a relative path to _var.scss.138 files, 129 of them component sheets
_tools.scss only forwards. It writes no CSS, or every component would emit a copy.var, config-var, breakpoint-mixin, mixin
src/assets/scss is on includePaths. That is why 'tools' resolves from any depth.the same file had five different relative paths
main.scss and its 24 partials compile as one unit. Do not mix @import and @use for the same partial inside it. Dart Sass stops the build.a partial that used $link_color with no load rule of its own
as * is deliberate. The old names keep working. Prefixing to var.$link_color would touch every call site for no change in output.too many call sites to do it as the first move
button.component.scss- @import '../../../assets/scss/var';- @import '../../../assets/scss/mixin';+ @use 'tools' as *;  .btn { color: $link_color; }src/assets/scss/_tools.scss+ @forward 'var';+ @forward 'config-var';+ @forward 'breakpoint-mixin';+ @forward 'mixin';angular.json, stylePreprocessorOptions  "includePaths": [    "sass",-   "."+   ".",+   "src/assets/scss"  ]ng serve, after+ 0 deprecation warnings from src/

After 21: library rules

RuleTraced to
Close a modal with the BsModalRef injected into it. BsModalService is provided once, at the root. A feature module that lists it again gets its own copy and a dialog that will not close.tag modal, camera modal. forum.module.ts still does this and only works by luck of the same instance.
Tab headings from selectTab are display strings. Map them to the enum before comparing, and expect the event on init.Messages tab
Every MediaStream track you open, you stop. That includes the one from a permission check.camera modal
A standalone component imports what its template uses. NG8113 is a warning; fix it in the same change.six components
Firebase is the SDK, called from FirebaseService. No AngularFire. SDK callbacks that touch view state run inside NgZone.run().AngularFire's only 21 build pulled Firebase 12
A library with no release for our Angular gets a patch-package diff, committed, applied by postinstall. Not a fork, not an edit in node_modules.ngx-intl-tel-input 15
tag.component.ts- import { BsModalService } from 'ngx-bootstrap/modal';+ import { BsModalRef } from 'ngx-bootstrap/modal';-   private readonly modalService: BsModalService,+   private readonly modalRef: BsModalRef,-   this.modalService.hide();+   this.modalRef.hide();dashboard.module.ts-   providers: [BsModalService, ...]+   providers: [...]message-container.component.ts- this.active = event.heading;+ this.active = event.heading === MESSAGES_SMALL ? MESSAGES : event.heading;

How we run the next upgrade

1
Bump one major
  • Versions and schematics only
  • Nothing else in the change
2
Grep the deprecations
  • Every symbol ng update named
  • Schematics missed five files in 16
3
Fix separately
  • One change per breakage
  • Keeps the cause obvious
4
Move the bundler alone
  • Same framework version
  • Never "new Angular" and "new builder" together
5
Prove it before merging
  • Prerender
  • The checks below, by hand
Manual checkWhat it caught this time
Start a live streamIVS CJS interop under esbuild
Open the text editorrequire('quill') at runtime
Seek in the audio playerMaterial 17 slider events
Multiselect with duplicate optionsPrimeNG 17 reference equality
npm run prerender-qa401 and status 0 on the server, domino layout calls
Server-render a page with sanitized HTMLDOMPurify default import
Open the flag pickerlazy flag-icons css path
Open a multiselect on a white-label sitePrimeNG 20 class renames, header slot moved
Open an ng-select on the dark themevendor sheet loading after our overrides
Open an image carousel with the OS in dark modeLara dark-mode tokens, nested .p-button
Added on 21What it caught
Tag someone in a post, close the dialogsecond BsModalService
Open Messages on a fresh loadselectTab fires on init
Livestream preview, then leavecamera track never released
Scroll a feed, resize the window49 listeners taking $event
Read the ng serve log to the endSass @import, NG8113

Dynamic imports and Material or PrimeNG components fail at runtime, behind a user action. Nothing in the build catches them. A library that renames its CSS classes breaks silently. A service provided twice is the same kind of silent. Screenshot the affected components before and after.

The build is 21. The source is not.

package.json says 21.2. The app still boots with bootstrapModule(AppModule), the shape it had on 12. Six majors of upgrade, almost none of the code Angular would write today. That was the plan. It is still worth seeing in numbers.

PracticeThis repoAngular 21
BootstrapbootstrapModule(AppModule)bootstrapApplication
Components533 standalone: false, 17 standalonestandalone by default
Templates6,148 *ngIf, 763 *ngFor@if, @for, since 17
Signals0 signal(), 0 computed()the state model
Inputs, queries1,412 @Input, 597 @ViewChildinput(), viewChild()
RouterRouterModule.forRoot, class guardsprovideRouter, CanActivateFn
SSRCommonEngine, no hydrationprovideClientHydration(withEventReplay())
Change detection92 of 666 OnPush, zone.jsOnPush, signals, zoneless
Unsubscribe1,142 takeUntil(destroy$)takeUntilDestroyed()
Forms747 UntypedForm*typed FormGroup

Already moved

  • The application builder, since 17.
  • provideZoneChangeDetection(). A patch so NgModule bootstrap still works on 21, not a modernisation.
  • provideHttpClient and two functional interceptors.
  • One functional guard, promotionalAuthGuard. The other eight guard files are still classes.
  • Vendor CSS split into lazy bundles.
  • Sass on @use. Zero @import in src/.

Counts from src/ on 22 September 2026. profile.component.html alone has 320 *ngIf. That file, and message-container at 144, are why a mechanical control-flow migration needs a human reading the diff. ng update tried it on the way to 21 and we reverted.

Deliberately left alone, and the order after this

ThingStatusWhy
NgModuleskept533 declared components, a working lazy-route tree. Its own project, and the first big one.
zone.jskeptStable in 21, and ng new asks for it. 46 files touch NgZone, 524 call detectChanges(). Signals and OnPush first, or it breaks quietly.
quill 1.3.7keptThe 21-era ngx-quill wants quill 2. The editor works, but 1.3.7 has XSS history. Not free to leave.
ng-ivs, ngx-audio-player, ngx-stripeout of rangePeer Angular 12, Material 17, Angular 18. Ran on 20 with --force, same on 21.
architect.serverlesslegacy builderbuild-angular:server with tsconfig.server.json. Builds, not on the main path.
Angular 22not yetWants Node 24.15+ and TypeScript 6. 21 holds until June 2027. A later branch.

Order of work, when we pick it up

  • provideClientHydration(withEventReplay()). We render on the server and then rebuild the tree in the browser. Hydration is the reason to keep SSR.
  • provideRouter(routes, withComponentInputBinding()). Convert the class guards to functions.
  • One leaf feature to standalone routes. Learn on something small.
  • @if / @for, file by file, with track. Wallet and feed first, profile last.
  • @defer around video, Stripe, Quill, Highcharts, emoji-mart. ngSrc on banner and merch images.
  • input(), output(), viewChild(). Then takeUntilDestroyed() for the 1,142 destroy$ pipes.
  • Typed forms as each form is touched. Vitest before the first spec lands, since there is nothing to port.