TopFans web frontend. Four majors, two bundlers, one SSR engine swap. NgModules, PrimeNG 17 and zone.js stay where they are.

What moved

ThingBeforeAfter
Angular15.2.1019.2.25
TypeScript4.9.55.8.3
zone.js0.110.15
rxjs7.57.8
Nodenode:20-slim, unpinned20.19.6, pinned
Client builderbrowser (webpack)application (esbuild)
SSR engine@nguniversal ngExpressEngine@angular/ssr CommonEngine
Server bundleserver/main.js (CJS)server/server.mjs (ESM)
PrimeNG15.417.18, held
Angular Material15.219.2
Component flagimplicitstandalone: false, explicit

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.

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

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.

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, floating18.20.8 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:18.20.8-slim.nvmrc (new)+ 18.20.8angular.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
Node18.20.820.19.6, the 18 line 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 gets an id of identity:occurrence, so duplicates are distinct. Sync back to the parent and markAsDirty; onBlur and onPanelHide mark touched; statusChanges mirrors disabled. Three passes to get all of it.
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 internalIdsByOption = new Map<unknown, string[]>(); + const identity = this.getOptionIdentity(option) || `index:${index}`;+ const occurrence = identityOccurrences.get(identity) || 0;+ const internalId = `${identity}:${occurrence}`;

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

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

What we do differently now

The previous pages say what changed. This page says what we write from here on. Every rule traces back to a specific breakage.

Module shape
Server code
Build config
Framework APIs
Next upgrade
Left alone

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
Lazy stylesheets go through the assets array and LAZY_CSS_LOADER_CONSTANTS.hrefs.flag-icons
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 and Dockerfile. They move together.Both changed twice on this path.
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'
@{{ 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. Duplicates or reconstructed values need the wrapper the custom multiselect uses.
(change)="seek($event.value)" on mat-sliderread input[matSliderThumb].valueAsNumber; listen to input too MatSliderChange is gone in Material 17+.

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

Dynamic imports and Material or PrimeNG components fail at runtime, behind a user action. Nothing in the build catches them. Screenshot the affected components before and after.

Deliberately left alone

ThingStatusWhy
NgModuleskeptHundreds of declared components and a working lazy-route tree. Standalone buys nothing until we want to drop modules for real. Its own project.
PrimeNG17.1818 replaces theming with design tokens and drops the CSS theme files we lazy-load. A styling migration, not an Angular one.
zone.jskeptZoneless is still experimental in 19. Nothing here depends on it.
Signals, control flow, inject()new code onlyUse them where they read better. Do not convert existing files as a side quest.
architect.serverlesslegacy builderStill build-angular:server with tsconfig.server.json. Builds, not on the main path. Next to fold into the application builder.

Where that leaves the repo

Angular19.2
Builderapplication
SSR@angular/ssr CommonEngine, ESM server
Component modelNgModules, standalone: false explicit
PrimeNG17.18
Node20.19.6
TypeScript5.8
Leftovertsconfig.server.json, serverless target