Angular 20 to 21 upgrade, revised
Move TopFans from Angular 20.3 to 21.2 before the v20 LTS window closes on 2026-11-28. Replace @angular/fire with the Firebase SDK, bump the libraries whose installed release rejects Angular 21, and leave zone.js, NgModules, Karma, and quill alone. Done means the scripts CI actually runs are green, the SSR log is clean, and push notifications work for both a first-time and a returning user.
What changed from the first draft
.npmrcalready haslegacy-peer-deps=true. Theoverridesidea is gone.ng updateruns its own peer check and needs--forcefor core and for Material.- CI runs Node 20 with npm 10.8.1, not the Node 24.5 / npm 12 on the dev machine. Phase 0 now checks the CodeBuild Node minor against Angular 21's
^20.19.0floor. - Untracked HTML plans in the repo root block
ng updatethe same way modified files do. - The Firebase test now covers a returning user. Today
onMessage()only runs when the token changes, so a user with a saved token gets no foreground listener. The rewrite fixes that. - Remote Config gets its own
isSupported()check.requestFirebaseToken()gets a browser guard. - The repo's custom
UrlSerializerhas a dedicated router change in 21 (#64449). It is on the checklist now. - Two missed
@HostBindingfiles, 35 CDK files instead of 25, Font Awesome's svg-core 7 dependency, andngx-scrollbar's deferral are all named. - Phase 5 runs the
prerender-all-*scripts that the buildspecs call, not the shorterprerender-qa.
Where the repo is today
| Item | Current | Notes |
|---|---|---|
| Angular core / CLI | 20.3.31 / 20.3.37 | Application builder, SSR via CommonEngine, prerender from prerender.routes.txt. |
| TypeScript | 5.8.3 | 21 needs >=5.9 <6.1. ng update moves it to 5.9.3. |
| Node / npm, dev machine | 24.5.0 / 12.0.1 | npm 12 warns it wants Node 24.15. Local annoyance only; see the CI row. |
| Node / npm, CI changed | nodejs: 20 / npm@10.8.1 | All four buildspec-*.yml files pin these and run npm install --legacy-peer-deps. Angular 21 needs Node ^20.19.0. Whether CodeBuild's "20" is 20.19 or later depends on the image. Unverified. |
| Install policy changed | .npmrc: legacy-peer-deps=true | Committed. npm ls exits 0 today because of it. CI passes the same flag explicitly. |
| Bootstrap | NgModule, zone.js 0.15.1 | platformBrowserDynamic().bootstrapModule(AppModule); AppServerModule imports AppModule. |
| Tests | Karma configured, zero .spec.ts | Every TestBed item in the official checklist is a no-op. |
| tsconfig | bundler, strict: false, strictTemplates: false | noUncheckedIndexedAccess and noUnusedLocals are on. |
| SSR hosts | allowedHosts set at server.ts:191 | Already satisfies the @angular/ssr 21.1.5 fix for GHSA-x288-3778-4hhx. |
| Router | Custom TrailingSlashUrlSerializer | Provided as UrlSerializer at app.module.ts:98. Overrides parse, delegates serialize. |
| Working tree | 8 modified, 12 untracked | Marketing route data, environment.ts, and HTML plans. All of it blocks ng update. |
Decisions
Replace @angular/fire with the firebase SDK. The only Angular 21 build is 21.0.0-rc.0, it depends on firebase ^12.4.0, and nothing newer has shipped. The repo uses four SDK functions and three providers. Calling firebase/messaging and firebase/remote-config directly removes the dependency and keeps Firebase at 11.10.0, the version installed today.
Do the Firebase swap first, on Angular 20. If push tokens break after the framework bump, the framework did it.
Use --force on both ng update commands and say why. changed ng update reads installed peerDependencies and refuses when any package rejects the target. @cotufa82/ng-ivs declares Angular 12, @khajegan/ngx-audio-player declares Angular 17 and Material 17, ngx-stripe declares Angular 18. Neither .npmrc nor overrides nor npm 12's packageExtensions affects that check. --force skips the check, not the migrations. It is the same situation the 20 upgrade was in.
Keep zone.js. 46 files touch NgZone. The 21 migration adds provideZoneChangeDetection() to AppModule on its own; that is the only change.
Keep Karma, skip Vitest. No spec files, nothing to run.
Leave ngx-quill 16.2.1 and quill 1.3.7. Peer is @angular/core >=13. ngx-quill 30 needs quill 2, which is its own project.
Upgrade rule. changed Bump a library when its installed release rejects Angular 21 or when a same-major patch exists for 21. Leave libraries with open peer ranges even if a newer major exists. That keeps ngx-scrollbar 9 (17 files use it; 19.1.5 exists with API changes), ngx-image-cropper 6, and ngx-sharebuttons 8 where they are.
Defer ng-ivs, ngx-audio-player, and ngx-stripe. All three already sit outside their declared range and run through the partial-compilation linker. ngx-stripe 21.9.0 would force @stripe/stripe-js to 9.x; the repo is on 3.5.0 and already outside ngx-stripe 18's own >=4 <5 range.
Stay on NgModules. No standalone, ngClass, or ngStyle migrations here.
Official 21 checklist, mapped to this repo
Every breaking change from the 21.0.0 release notes plus the CLI-level items. Each row says whether it applies and what to do.
| Item | Applies | Action |
|---|---|---|
ng update @angular/core@21 @angular/cli@21 | yes | Phase 2, with --force. Lands core 21.2.23, CLI 21.2.24. |
ng update @angular/material@21 | yes | Phase 2, with --force because ngx-audio-player peers Material 17. Lands 21.2.14. |
| TypeScript 5.9 required | yes | ng update bumps to 5.9.3. Watch noUncheckedIndexedAccess and noUnusedLocals. |
Zone apps must add provideZoneChangeDetection() | yes | Mandatory migration, runs without a prompt. Confirm it appears in AppModule.providers. Without it the app renders once and never updates. |
| Host binding type checking on by default | verify | Eight @HostBinding uses in five files: header.component.ts, header-user-menu.component.ts, header-layout.component.ts, profile-skeleton.component.ts, signup-by-refer.component.ts. Plus video-player-v2's five host class bindings. Fix types first; typeCheckHostBindings: false only if a fix changes behaviour. |
Custom UrlSerializer query-param handling (#64449) changed | verify | The router now defers to the custom serializer for query params. TrailingSlashUrlSerializer only overrides parse, so this is probably fine, but test /username/?key=x, /creators?key=x, and a hash URL in Phase 6. |
| Router navigations take extra microtasks | verify | 10 files subscribe to router.events. Smoke-test navigation, back/forward, lazy loads. |
@angular/ssr 21.1.5 allowedHosts | done | getSsrAllowedHosts() in server.ts already covers it. |
BootstrapContext for server bootstrap | n/a | Only affects bootstrapApplication. CommonEngine still takes an NgModule type. |
moduleId, interpolation, ngComponentOutletContent, ApplicationConfig import, ignoreChangesOutsideZone, lastSuccessfulNavigation, NgModuleFactory, UpgradeAdapter, emitDeclarationOnly | n/a | Zero occurrences of each. |
New formArray directive | n/a | Only formArrayName is used (35 templates). |
| Signal inputs on custom elements | n/a | @angular/elements is in package.json but never imported. No signal inputs anywhere. |
| TestBed changes | n/a | No spec files. |
Dependency matrix
Angular
| Package | Now | Target | How |
|---|---|---|---|
@angular/* framework packages | 20.3.31 | 21.2.23 | ng update |
@angular/cli, @angular/ssr, @angular/pwa, @angular-devkit/build-angular | 20.3.37 | 21.2.24 | ng update. karma and server builders still ship in 21.2.24. |
@angular-devkit/architect | 0.2003.37 | 0.2102.24 | ng update. Own version scheme. |
@angular/material, @angular/cdk | 20.2.14 | 21.2.14 | ng update @angular/material@21 --force |
typescript | 5.8.3 | 5.9.3 | ng update |
zone.js | 0.15.1 | whatever ng update picks | Peer is ~0.15 || ~0.16. Do not bump by hand. |
@angular-eslint/* | 19.8.1 | 21.4.0 | Manual. Peer allows eslint ^8.57, so .eslintrc.json stays. |
Firebase
| Package | Now | Target | How |
|---|---|---|---|
@angular/fire | 20.0.1 | removed | Phase 1. |
firebase | 11.10.0, transitive | 11.10.0, direct | Exact version, not a range. Firebase 12 is a separate task. |
Libraries that reject Angular 21 today
| Package | Now | Target | Note |
|---|---|---|---|
primeng | 20.4.0 | 21.1.10 | Peers @angular/core ^21.0.7. |
ngx-bootstrap | 20.0.2 | 21.2.2 | Peers ^21.2.0. Modal and datepicker are app-wide. |
@ng-select/ng-select | 20.4.0 | 21.8.2 | Theme CSS path unchanged. |
@ngrx/store | 20.1.0 | 21.1.1 | ng update @ngrx/store@21 has a schematic. |
@ng-bootstrap/ng-bootstrap | 19.0.1 | 20.0.0 | Only NgbActiveModal is used. Also peers @angular/localize ^21, which ng update handles. |
ngx-toastr | 19.1.0 | 20.0.5 | lazy-toastr bundle path unchanged. |
ngx-mask | 20.0.3 | 21.1.0 | |
ngx-owl-carousel-o | 20.1.1 | 21.0.3 | Carousel theme regressions were fixed in 35f314c117; re-check those views. |
ngx-infinite-scroll | 20.0.0 | 21.0.0 | |
@zxing/ngx-scanner | 20.0.0 | 21.0.0 | Peers TypeScript ^5.9; fine after Phase 2. |
@fortawesome/angular-fontawesome changed | 2.0.1 | 4.0.0 | Depends on fontawesome-svg-core ^7.1.0. Bump fontawesome-svg-core, free-brands-svg-icons, free-solid-svg-icons to ^7.3.1 together or the tree carries two core majors. Only two icons are registered: faXTwitter, faCommentSms. |
ngx-device-detector | 10.1.0 | 11.0.0 |
Left alone
Open peer range. Install cleanly on 21.
ngx-quill16.2.1,quill1.3.7,quill-emoji@ctrl/ngx-emoji-mart9.3.0angular-mentions,ng-circle-progress,ng-otp-inputngx-clipboard,ngx-color-picker,ngx-editor13,ngx-image-compressngx-scrollbar9 (19.1.5 exists, API changes, 17 files),ngx-image-cropper6 (9.1.7 exists),ngx-sharebuttons8 (17.0.0 exists). Newer majors are a separate task.ngx-material-timepicker,ngx-intl-tel-input15
Out of range, deferred, need --force.
@cotufa82/ng-ivs0.1.0 declares Angular 12. No newer release.@khajegan/ngx-audio-player17.1.0 declares Angular 17 and Material 17. No newer release.ngx-stripe18.1.0 declares Angular 18 and@stripe/stripe-js >=4 <5. 21.9.0 needs stripe-js 9.x.
npm ls does not complain about these because .npmrc turns peer checking off. ng update checks anyway.
Firebase: the SDK without AngularFire
Everything Firebase-related is in two files plus the service worker. The public shape of FirebaseService does not change, so its 14 consumers are untouched.
| File | Change |
|---|---|
package.json | Remove @angular/fire. Add "firebase": "11.10.0" to dependencies. |
src/app/app.module.ts | Delete provideFirebaseApp, provideMessaging, provideRemoteConfig and their imports. Initialization moves into the service. |
src/app/modules/shared/services/firebase.service.ts | Replace inject(Messaging) and the RemoteConfig constructor param with lazy, browser-only getters. Import initializeApp, getApps from firebase/app; getMessaging, getToken, onMessage, isSupported from firebase/messaging; getRemoteConfig, fetchAndActivate, getValue, isSupported from firebase/remote-config. Alias the two isSupported imports. |
src/firebase-messaging-sw.js | No change. Compat 9.6.1 from gstatic, never depended on AngularFire. |
environment*.ts | No change. |
Contract the rewritten service must keep
- Public members unchanged:
requestFirebaseToken(),onMessage(),getRemoteConfigValue(),logOutToken(),setConfigToken(),generateFingerprint(),deviceToken,$deviceId,iosPayButtonsConfig. - Never touch
firebase/messagingorfirebase/remote-configon the server. AngularFire returnednullthere and the&& this.remoteConfigcheck hid it. The raw SDK throws. GuardgetRemoteConfigValue()andrequestFirebaseToken()withplatformService.isBrowser. changed The token path has no guard today; its only caller isheader.component.ts:825. - Call each product's
isSupported()before itsget*(). changed Messaging returns false on non-secure origins and some Safari modes. Remote Config returns false when IndexedDB exists but rejectsopen(), which happens in private browsing. - Initialize once: check
getApps().lengthbeforeinitializeApp(environment.firebaseConfig). White-label builds swap the config viafileReplacements. - Attach the foreground listener on every browser session, not only when the token changes. changed Today
onMessage()is called only insidesetToken()'s subscribe, andsetToken()runs only when the token differs fromfc_tokenin localStorage. A returning user gets no listener. Register the token when it changed; attachonMessageregardless; keep the unsubscribe handle. - Wrap the toast call and the
iosPayButtonsConfigassignment inNgZone.run(). AngularFire did this for us. Zone.js probably catches the underlying events anyway, but the explicit call costs two lines and removes the question. Leave a comment pointing the future zoneless task at it. - Bundle size should be flat or smaller: the same
firebase/*chunks were already there, andrxfireplus the AngularFire wrappers drop out.
Sequence
Phase 0. Baseline and toolchain
- Commit the 8 modified files and the 12 untracked HTML plans in their own commit, or move the plans out of the repo.
ng updatechecksgit status --porcelainand??entries count. changed - Find the Node version CodeBuild's
nodejs: 20runtime resolves to on the image the buildspecs use. Angular 21 needs>=20.19.0. If it is older, bumpruntime-versionsto 22 in all four buildspecs as part of this branch. changed - Locally, either move to Node 24.15 or ignore the npm 12 warning. It does not affect CI, which installs its own npm 10.8.1.
npm ci, thennpm run build-dev. Record initial bundle sizes and theanyComponentStylewarnings.npm run prerender-all-qaandnpm run serve:ssr. Confirm/and one creator profile render server-side.
Checkpoint: green build on Angular 20 with numbers written down and a known CI Node version.
Phase 1. Firebase SDK swap, still on Angular 20
- Apply the file changes above.
npm install. - Fresh profile: clear
fc_tokenandfc_is_appfrom localStorage, allow notifications, log in. ExpectPOST pushNotificationwith a token and a test push arriving as a toast. - Returning user: reload with the saved token. Expect no duplicate POST and the test push still arriving as a toast. changed
- Remote Config:
iosPayButtonsConfigpopulated fromPayment_Configon load. Repeat in a private window; expect a clean skip, not a console error. changed - Logout hits
PUT pushNotificationwith the token. - Build dev and SSR again. No Firebase errors in the SSR log.
Checkpoint: commit replace @angular/fire with firebase sdk. The framework has not moved, so any breakage here is ours.
Phase 2. Framework
ng update @angular/core@21 @angular/cli@21 --force
ng update @angular/material@21 --force
--forceis expected. The CLI will list@cotufa82/ng-ivs,@khajegan/ngx-audio-player, andngx-stripeas incompatible. If it names anything else, stop and look. changed- The zone migration runs automatically. Confirm
provideZoneChangeDetection()is inAppModule.providers. - Review the diff in
angular.json,tsconfig*.json,package.json. It should be small.
Checkpoint: commit angular 21 core, cli, material. The build may not be green yet.
Phase 3. Libraries
ng update @ngrx/store@21 --force
npm i primeng@21 ngx-bootstrap@21 @ng-select/ng-select@21 @ng-bootstrap/ng-bootstrap@20 \
ngx-toastr@20 ngx-mask@21 ngx-owl-carousel-o@21 ngx-infinite-scroll@21 \
@zxing/ngx-scanner@21 ngx-device-detector@11 \
@fortawesome/angular-fontawesome@4 @fortawesome/fontawesome-svg-core@7 \
@fortawesome/free-brands-svg-icons@7 @fortawesome/free-solid-svg-icons@7
npm i -D @angular-eslint/builder@21 @angular-eslint/eslint-plugin@21 \
@angular-eslint/eslint-plugin-template@21 @angular-eslint/schematics@21 \
@angular-eslint/template-parser@21
- Read each library's 21 changelog before fixing compile errors.
ngx-bootstrapandprimengare the likeliest to have moved something. - Font Awesome: confirm
npm ls @fortawesome/fontawesome-svg-coreshows one version.
Checkpoint: commit angular 21 third-party libraries.
Phase 4. Make it compile
npm run build-dev. Fix in order: TypeScript 5.9 errors, host binding type errors, library API changes.- Host bindings: fix the getter's return type first.
typeCheckHostBindings: falseonly if a fix would change behaviour. npm run lint. Fix what@angular-eslint21 flags or add targeted disables with a reason.
Checkpoint: commit fix compile and lint for angular 21. Dev build green.
Phase 5. The builds CI runs changed
The buildspecs run npm run $BUILD_ENV. Run the scripts that variable actually takes, not shorter stand-ins.
npm run build-dev,npm run build-qa,npm run build-stage,npm run build-prod. Compare initial bundle and component style sizes with Phase 0.npm run prerender-all-qaandnpm run prerender-all-staging. These run prefetch, static route generation, the prerender build, and sitemap generation.buildspec-s3.ymlgates an npm audit on exactly these two names.npm run serve:ssrafter the QA prerender. Hit/, a creator profile, a marketing page, a 404,/creators?key=x, and/username/?key=x. Read the server log, not just the status codes.npm run build-qa:slsandnpm run build-stage:slsto prove theserverbuilder still runs for the serverless target.npm run generate-white-label-qafor one white-label project, then build it. The generator writesangular.jsonprojects; confirm 21 accepts what it emits.
Checkpoint: every listed script green, sizes within budget, SSR log clean. Push the branch and let CodeBuild run it on Node 20 / npm 10.8.1.
Phase 6. Hands-on smoke test
- Login, logout, OTP flow (uses
deviceToken). - Push token registration and an inbound toast, fresh and returning.
- Payment flow on an iOS user agent. Remote Config decides which buttons show.
- Router: deep link, back/forward, trailing-slash redirect, query params through the custom serializer, lazy module load.
- Material bottom sheets, CDK drag-and-drop ordering, CDK clipboard copy, the mobile breakpoint switch (
cdk/layoutinmobile-optimization.service.ts), promotional list scrolling (cdk/scrolling). changed ng-selectdropdowns,ngx-bootstrapmodals and datepicker, toasts, PrimeNG widgets, owl carousels, QR scanner, infinite scroll, masked inputs, the two Font Awesome icons.- Audio player and IVS live stream, the two libraries running past their declared range.
- Service worker registration in a prod-config build.
Checkpoint: open the PR against feature/release-0.0.23/16-17-18-19-20 or the release branch the team prefers.
Risks
| Risk | Why it matters here | Mitigation |
|---|---|---|
| CI Node version changed | Local is Node 24.5, CI is Node 20. If the image's 20 is older than 20.19, the branch passes locally and fails in the pipeline. | Phase 0 check. Bump buildspecs to 22 if needed. |
| SSR regressions | Custom server.ts with domino, a hand-rolled URL shim, installServerDom, prerender-first routing. Framework majors tend to bite this layer. | Phase 0 baseline, Phase 5 route list, read the SSR log. |
| Host binding type errors | New default in 21. strictTemplates is off, so these bindings have never been type-checked. | Fix types; documented fallback flag. |
| Firebase behaviour drift | AngularFire zone-wrapped calls, returned null on the server, and swallowed unsupported-browser errors. The raw SDK does none of that. | Browser guards on both entry points, per-product isSupported(), NgZone.run(), two-state push test, Phase 1 before the framework moves. |
| Three libraries compiled against Angular 12, 17, 18 | They run through the partial-compilation linker. One more major is usually fine, not guaranteed. | Smoke-test audio and IVS in Phase 6. If one breaks, pull the deferred task forward. |
| Router changes | Microtask timing, plus the custom-serializer fix in #64449. | Phase 6 navigation and query-param cases. |
| White-label generator | Generates angular.json projects from the API. If the template hardcodes 20-shaped options, every white-label build breaks at once. | Generate and build one in Phase 5. |
Open questions and assumptions
- CodeBuild image. Which image do the four buildspecs run on, and what does its
nodejs: 20resolve to? This decides whether Phase 0 touches the buildspecs. - Remote Config during prerender. Assumed not needed. It only drives iOS payment buttons, never part of prerendered marketing HTML. The service becomes browser-only.
- Returning-user listener. Assumed the current behaviour (no
onMessagewhen the token is unchanged) is a bug, not a product decision. If it was deliberate, say so and the contract changes. - Firebase 12. Out of scope. Pinning
11.10.0keeps the Firebase surface identical to today. - PR target. Branch naming suggests this stacks on
16-17-18-19-20. Confirm whether that merges first.
Out of scope
- Zoneless change detection,
OnPushconversions, signal forms. - Vitest or any test setup.
- Standalone components,
CommonModuleremoval,ngClass/ngStylemigrations,inject()migration. - Moving the
serverlessandtesttargets off@angular-devkit/build-angular. Both builders still ship in 21.2.24. Revisit before 22. platformBrowserDynamictoplatformBrowserinmain.ts. Deprecated, still works.- quill 2, Firebase 12,
ngx-stripe21,ng-ivs,ngx-audio-player,ngx-scrollbar19,ngx-image-cropper9,ngx-sharebuttons17. - Angular 22. Needs Node
^22.22.3 || ^24.15.0and TypeScript 6.0. Separate branch once this lands.