@angular/upgrade (ngUpgrade) module to run AngularJS and Angular side-by-side in a hybrid app, then migrate components incrementally bottom-up until no AngularJS code remains. [src1, src2]import { UpgradeModule } from '@angular/upgrade/static'.component() API introduced in 1.5 is required for ngUpgrade. Apps on 1.2-1.4 must first upgrade to 1.8.x. [src1]angular.bootstrap() alongside platformBrowserDynamic().bootstrapModule() -- this creates two separate apps that cannot share DI. Always use UpgradeModule. [src1, src7]standalone: true for all components. New components written during migration should use standalone APIs unless your hybrid setup requires NgModule. [src1]| AngularJS Pattern | Angular Equivalent | Example |
|---|---|---|
angular.module('app', []) | @NgModule({ ... }) or standalone components | @Component({ standalone: true, imports: [...] }) |
$scope / $rootScope | Component class properties + @Input/@Output | @Input() name: string; |
.controller('Ctrl', fn) | @Component({ ... }) class Ctrl | @Component({ selector: 'app-ctrl', template: '...' }) |
.directive('myDir', fn) | @Directive({ selector: '[myDir]' }) or @Component | @Directive({ selector: '[highlight]' }) |
.service('svc', fn) / .factory(...) | @Injectable({ providedIn: 'root' }) | @Injectable({ providedIn: 'root' }) class SvcService |
.filter('fmt', fn) | @Pipe({ name: 'fmt' }) | @Pipe({ name: 'fmt' }) class FmtPipe implements PipeTransform |
$http.get(url) | HttpClient.get(url) | this.http.get<Data[]>('/api/data') |
$q.defer() / promises | RxJS Observable (preferred) or native Promise | this.http.get(url).pipe(map(res => res.data)) |
$routeProvider / ui-router | RouterModule.forRoot(routes) | { path: 'home', component: HomeComponent } |
ng-repeat="item in items" | *ngFor="let item of items" (or @for in Angular 17+) | @for (item of items; track item.id) { ... } |
ng-if="condition" | *ngIf="condition" (or @if in Angular 17+) | @if (isLoaded) { <app-data /> } |
ng-model="value" | [(ngModel)]="value" (FormsModule) or reactive forms | <input [(ngModel)]="name"> / formControlName="name" |
$broadcast / $emit | EventEmitter, RxJS Subject, or Angular Signals | @Output() changed = new EventEmitter<string>(); |
$watch('expr', fn) | RxJS streams, OnChanges, or Angular Signals + effect() | effect(() => console.log(this.count())) |
$compile(html)($scope) | Dynamic components via ViewContainerRef | viewRef.createComponent(MyComponent) |
START
|-- Is your AngularJS app < 5,000 LOC?
| |-- YES -> Consider a full rewrite (faster than hybrid setup overhead)
| +-- NO v
|-- Is the app on AngularJS 1.5+ with .component() API?
| |-- YES -> Proceed with ngUpgrade incremental migration [Step 1]
| +-- NO -> First upgrade to AngularJS 1.8.x and refactor to component architecture [Step 1]
|-- Do you use ui-router extensively?
| |-- YES -> Install @uirouter/angular-hybrid for parallel routing
| +-- NO v
|-- Do you need both frameworks' routes simultaneously?
| |-- YES -> Use setUpLocationSync() from @angular/upgrade/static [Step 5]
| +-- NO v
|-- Is the team experienced with TypeScript?
| |-- YES -> Migrate services first (bottom-up), then components
| +-- NO -> Invest 2-4 weeks in TypeScript training before starting migration
|-- Target Angular version?
| |-- Angular 19+ -> Use standalone components by default, plan for zoneless (Angular 21+)
| +-- Angular 17-18 -> Use NgModule or standalone (both supported)
+-- DEFAULT -> Use ngUpgrade hybrid approach, migrate leaf components first, work upward
Refactor the AngularJS app to follow the component-based architecture introduced in AngularJS 1.5. Convert controllers + templates into .component() definitions. Follow the "Rule of One" -- one component per file. If on AngularJS 1.2-1.4, first upgrade to 1.8.x. [src1, src2]
// BEFORE: controller-based (AngularJS 1.x style)
angular.module('app').controller('HeroListCtrl', function($scope, HeroService) {
$scope.heroes = [];
HeroService.getAll().then(function(heroes) {
$scope.heroes = heroes;
});
});
// AFTER: component-based (AngularJS 1.5+ style -- required for ngUpgrade)
angular.module('app').component('heroList', {
template: '<div ng-repeat="hero in $ctrl.heroes">{{hero.name}}</div>',
controller: function(HeroService) {
this.$onInit = function() {
HeroService.getAll().then(heroes => this.heroes = heroes);
};
}
});
Verify: grep -r "$scope" src/ --include="*.js" | wc -l → expected: 0
Create a new Angular project using Angular CLI. Install the upgrade package. Configure the build system to compile both AngularJS and Angular code. [src1, src7]
# Create Angular project (if not already existing)
ng new my-app --skip-install
cd my-app
# Install ngUpgrade
npm install @angular/upgrade
# If using ui-router, install the hybrid adapter
npm install @uirouter/angular-hybrid
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { UpgradeModule } from '@angular/upgrade/static';
@NgModule({
imports: [
BrowserModule,
UpgradeModule
]
})
export class AppModule {
constructor(private upgrade: UpgradeModule) {}
ngDoBootstrap() {
// Bootstrap AngularJS inside Angular
this.upgrade.bootstrap(document.body, ['myAngularJSApp'], { strictDi: true });
}
}
Verify: ng build compiles without errors. The app loads and AngularJS components still render.
Create your first Angular component. Use downgradeComponent to register it as an AngularJS directive so it can be used in AngularJS templates. [src1, src2]
// src/app/hero-detail/hero-detail.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-hero-detail',
template: `<h3>{{ hero.name }}</h3><p>ID: {{ hero.id }}</p>`
})
export class HeroDetailComponent {
@Input() hero: { id: number; name: string };
}
// src/app/hero-detail/hero-detail.downgrade.ts
import { downgradeComponent } from '@angular/upgrade/static';
import { HeroDetailComponent } from './hero-detail.component';
// Register as AngularJS directive
angular.module('myAngularJSApp')
.directive('appHeroDetail', downgradeComponent({
component: HeroDetailComponent
}));
<!-- Now usable in any AngularJS template -->
<app-hero-detail [hero]="$ctrl.selectedHero"></app-hero-detail>
Verify: The Angular component renders inside AngularJS templates. Inspect the DOM -- the element should have an ng-version attribute.
Rewrite AngularJS services as Angular @Injectable classes. Downgrade them for backward compatibility with remaining AngularJS components. [src2, src5]
// src/app/services/hero.service.ts -- new Angular service
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
interface Hero {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class HeroService {
constructor(private http: HttpClient) {}
getAll(): Observable<Hero[]> {
return this.http.get<Hero[]>('/api/heroes');
}
getById(id: number): Observable<Hero> {
return this.http.get<Hero>(`/api/heroes/${id}`);
}
}
// Downgrade for AngularJS consumption
import { downgradeInjectable } from '@angular/upgrade/static';
import { HeroService } from './services/hero.service';
angular.module('myAngularJSApp')
.factory('heroService', downgradeInjectable(HeroService));
Verify: AngularJS components using the downgraded service still function. Check network tab -- HTTP calls go through Angular's HttpClient.
Configure Angular Router alongside AngularJS routing. Use setUpLocationSync() to keep both routers synchronized. [src1, src2]
// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
// New Angular routes
{ path: 'dashboard', component: DashboardComponent },
{ path: 'heroes/:id', component: HeroDetailComponent },
// Catch-all: let AngularJS handle unknown routes
{ path: '**', component: AngularJSRouteComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
// In AppModule -- sync location between frameworks
import { setUpLocationSync } from '@angular/upgrade/static';
import { Router } from '@angular/router';
export class AppModule {
constructor(private upgrade: UpgradeModule, private router: Router) {}
ngDoBootstrap() {
this.upgrade.bootstrap(document.body, ['myAngularJSApp'], { strictDi: true });
setUpLocationSync(this.upgrade);
}
}
Verify: Navigate between AngularJS and Angular routes. URL changes reflect in both frameworks. Browser back/forward buttons work correctly.
Work from leaf components (no child dependencies) upward. For each component: rewrite in Angular, replace the AngularJS directive with a downgraded version, remove the old AngularJS code. Track progress by counting remaining AngularJS controllers. [src2, src3]
# Track migration progress
echo "AngularJS controllers remaining:"
grep -r "\.controller(" src/ --include="*.js" | wc -l
echo "AngularJS components remaining:"
grep -r "\.component(" src/ --include="*.js" | wc -l
echo "Angular components created:"
find src/app -name "*.component.ts" | wc -l
Verify: After each component migration, run the full test suite: ng test && npm run e2e
Once all components and services are migrated, remove ngUpgrade, AngularJS dependencies, and the hybrid bootstrap. Switch to pure Angular bootstrap. For Angular 19+, migrate to standalone bootstrap using bootstrapApplication. [src1, src3]
# Remove AngularJS dependencies
npm uninstall angular @angular/upgrade
# If using ui-router hybrid
npm uninstall @uirouter/angular-hybrid @uirouter/angularjs
# Remove from angular.json -- delete any AngularJS script includes
# Update main.ts to standard Angular bootstrap
// src/main.ts -- clean Angular bootstrap (standalone, Angular 19+)
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig)
.catch(err => console.error(err));
Verify: ng build --configuration production succeeds. No references to angular.module, $scope, or @angular/upgrade in codebase.
// Input: An existing AngularJS service registered as angular.module('app').service('userService', ...)
// Output: An Angular provider that wraps the AngularJS service for use in Angular components
// src/app/ajs-upgraded-providers.ts
import { InjectionToken } from '@angular/core';
// Define a token for the AngularJS service
export const USER_SERVICE = new InjectionToken<any>('UserService');
// Factory that extracts the AngularJS service via $injector
export function userServiceFactory(i: any) {
return i.get('userService');
}
// Provider configuration for Angular module
export const userServiceProvider = {
provide: USER_SERVICE,
useFactory: userServiceFactory,
deps: ['$injector']
};
// src/app/profile/profile.component.ts -- using the upgraded service
import { Component, Inject, OnInit } from '@angular/core';
import { USER_SERVICE } from '../ajs-upgraded-providers';
@Component({
selector: 'app-profile',
template: `
<div *ngIf="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
`
})
export class ProfileComponent implements OnInit {
user: any;
constructor(@Inject(USER_SERVICE) private userService: any) {}
ngOnInit() {
this.userService.getCurrentUser().then((user: any) => {
this.user = user;
});
}
}
// Input: An Angular component with @Input and @Output bindings
// Output: A downgraded AngularJS directive that preserves all bindings
// src/app/search-bar/search-bar.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-search-bar',
template: `
<input [value]="query" (input)="onInput($event)" placeholder="Search...">
<button (click)="onSearch()">Search</button>
`
})
export class SearchBarComponent {
@Input() query: string = '';
@Output() queryChange = new EventEmitter<string>();
@Output() search = new EventEmitter<string>();
onInput(event: Event) {
this.query = (event.target as HTMLInputElement).value;
this.queryChange.emit(this.query);
}
onSearch() {
this.search.emit(this.query);
}
}
// src/app/search-bar/search-bar.downgrade.ts
import { downgradeComponent } from '@angular/upgrade/static';
import { SearchBarComponent } from './search-bar.component';
angular.module('myAngularJSApp')
.directive('appSearchBar', downgradeComponent({
component: SearchBarComponent
}));
<!-- Usage in AngularJS template -- note: kebab-case attributes for Angular bindings -->
<app-search-bar
[query]="$ctrl.searchQuery"
(query-change)="$ctrl.searchQuery = $event"
(search)="$ctrl.doSearch($event)">
</app-search-bar>
Full script: javascript-automated-migration-progress-tracker-sc.js (56 lines)
// Input: Project source directory path
// Output: Migration progress report (AngularJS vs Angular component counts)
// scripts/migration-progress.js
const { execSync } = require('child_process');
function countPattern(dir, pattern, ext) {
try {
const result = execSync(
`grep -r "${pattern}" "${dir}" --include="*.${ext}" -l 2>/dev/null | wc -l`,
{ encoding: 'utf-8' }
);
return parseInt(result.trim(), 10);
} catch { return 0; }
}
const srcDir = process.argv[2] || './src';
const report = {
angularjs: {
controllers: countPattern(srcDir, '\\.controller(', 'js'),
directives: countPattern(srcDir, '\\.directive(', 'js'),
services: countPattern(srcDir, '\\.service(\\|.factory(', 'js'),
filters: countPattern(srcDir, '\\.filter(', 'js'),
},
angular: {
components: countPattern(srcDir, '@Component', 'ts'),
directives: countPattern(srcDir, '@Directive', 'ts'),
services: countPattern(srcDir, '@Injectable', 'ts'),
pipes: countPattern(srcDir, '@Pipe', 'ts'),
}
};
const totalAJS = Object.values(report.angularjs).reduce((a, b) => a + b, 0);
const totalNG = Object.values(report.angular).reduce((a, b) => a + b, 0);
const pct = totalNG + totalAJS > 0
? Math.round((totalNG / (totalNG + totalAJS)) * 100) : 0;
console.log(`Progress: ${pct}% migrated (${totalNG}/${totalNG + totalAJS})`);
// BAD -- manually calling angular.bootstrap creates two separate apps
// that cannot share services or components
platformBrowserDynamic().bootstrapModule(AppModule);
angular.bootstrap(document.body, ['myApp']); // Separate bootstrap!
// GOOD -- UpgradeModule creates a single hybrid app with shared DI
@NgModule({
imports: [BrowserModule, UpgradeModule]
})
export class AppModule {
constructor(private upgrade: UpgradeModule) {}
ngDoBootstrap() {
this.upgrade.bootstrap(document.body, ['myApp'], { strictDi: true });
}
}
platformBrowserDynamic().bootstrapModule(AppModule);
// BAD -- $scope creates tight coupling to AngularJS digest cycle
// and cannot be migrated to Angular
angular.module('app').component('userCard', {
controller: function($scope, UserService) {
$scope.user = null;
$scope.$watch('userId', function(id) {
UserService.get(id).then(function(u) { $scope.user = u; });
});
}
});
// GOOD -- component bindings map directly to Angular @Input/@Output
angular.module('app').component('userCard', {
bindings: { userId: '<' },
controller: function(UserService) {
this.$onChanges = function(changes) {
if (changes.userId && changes.userId.currentValue) {
UserService.get(this.userId).then(u => this.user = u);
}
};
}
});
# BAD -- rewriting the entire app in one branch
git checkout -b full-angular-rewrite
# ... 6 months of parallel development ...
# Result: massive merge conflicts, feature drift, missed deadlines
# GOOD -- migrate one component at a time, merge to main frequently
git checkout -b migrate/hero-detail-component
# Migrate one component, downgrade for AngularJS compatibility
# Run tests, deploy, merge
git checkout -b migrate/hero-service
# Next component...
// BAD -- maintaining separate state in both Angular and AngularJS
// leads to synchronization bugs
// In Angular:
this.cartItems = [...]; // Angular state
// In AngularJS:
$scope.cartItems = [...]; // AngularJS state -- gets out of sync!
// GOOD -- one service owns the state, shared via downgrade
@Injectable({ providedIn: 'root' })
export class CartService {
private items = new BehaviorSubject<CartItem[]>([]);
items$ = this.items.asObservable();
addItem(item: CartItem) {
this.items.next([...this.items.value, item]);
}
}
// Downgrade for AngularJS
angular.module('app').factory('cartService', downgradeInjectable(CartService));
// BAD -- every AngularJS digest triggers Angular change detection
// and vice versa, creating cascading cycles
// No optimization = 2x-5x slower than either framework alone
// GOOD -- OnPush prevents unnecessary change detection in hybrid mode
@Component({
selector: 'app-hero-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div *ngFor="let hero of heroes$ | async">{{ hero.name }}</div>`
})
export class HeroListComponent {
heroes$ = this.heroService.getAll();
constructor(private heroService: HeroService) {}
}
// BAD -- .toPromise() is deprecated since RxJS 7
const heroes = await this.heroService.getAll().toPromise();
// GOOD -- firstValueFrom() is the modern replacement (RxJS 7+)
import { firstValueFrom } from 'rxjs';
const heroes = await firstValueFrom(this.heroService.getAll());
ChangeDetectionStrategy.OnPush on all migrated Angular components and minimize cross-framework component boundaries. [src2, src4]$scope.$apply() when Angular Zone.js already triggers a digest causes "$apply already in progress" errors. Fix: Remove explicit $scope.$apply() calls; Zone.js handles triggering the AngularJS digest automatically in hybrid mode. [src1, src4]setUpLocationSync(this.upgrade) after bootstrap, and ensure routes are partitioned -- each URL is handled by exactly one framework. [src1, src2]declarations and entryComponents arrays in NgModule (for Angular < 13; Ivy makes this optional). Fix: Add the component to entryComponents: [MyComponent] in the NgModule, or upgrade to Angular 13+ where Ivy handles this automatically. [src1, src7]$q promises everywhere; Angular uses RxJS Observables. Mixing them without conversion causes silent failures. Fix: Use firstValueFrom() (RxJS 7+) when passing Observable results to AngularJS code. .toPromise() is deprecated. [src5]'myService'); Angular uses class references or InjectionToken. Downgraded/upgraded services must use the correct token type for each framework. Fix: Use downgradeInjectable(ServiceClass) with a matching .factory('serviceName', ...) registration. [src5, src7]angular-ui-bootstrap, angular-translate, or angular-material have no ngUpgrade path. Fix: Replace with Angular equivalents (Angular Material, ngx-translate, ng-bootstrap) before or during migration, or wrap with a thin AngularJS component boundary. [src3, src6]signal(), computed(), and effect() in new Angular components. [src4]# Check AngularJS version
node -e "console.log(require('./node_modules/angular/package.json').version)"
# Check Angular CLI and framework version
ng version
# Count remaining AngularJS artifacts
grep -r "angular\.module\|\.controller(\|\.directive(\|\.factory(\|\.service(" src/ --include="*.js" | wc -l
# Find all $scope references (should be 0 before migration)
grep -rn "\$scope" src/ --include="*.js" --include="*.ts"
# Check for circular dependencies
npx madge --circular src/app
# Verify no AngularJS code remains after migration
grep -r "angular\." src/ --include="*.ts" --include="*.js" | grep -v node_modules | grep -v ".spec."
# Run Angular production build to catch any issues
ng build --configuration production
# Analyze bundle size (check for leftover AngularJS)
npx webpack-bundle-analyzer dist/my-app/stats.json
# Check for deprecated APIs before removing Zone.js (Angular 19+)
grep -rn "NgZone\|onMicrotaskEmpty\|onUnstable\|isStable\|onStable" src/ --include="*.ts"
| Version | Status | ngUpgrade Support | Migration Notes |
|---|---|---|---|
| AngularJS 1.8.x | EOL (Dec 2021) | Required source | Last AngularJS release. Must be on 1.5+ for component API. [src6] |
| Angular 2-5 | EOL | Full | Original ngUpgrade. Requires SystemJS or custom webpack config. |
| Angular 6-8 | EOL | Full | Angular CLI builder. @angular/upgrade/static is the stable API. |
| Angular 9-12 | EOL | Full (Ivy) | Ivy renderer. entryComponents still required until Angular 13. |
| Angular 13-15 | EOL | Full (Ivy) | entryComponents removed. ViewEngine dropped in 13. |
| Angular 16-17 | EOL | Full | Standalone components, signals, new control flow syntax (@if, @for). [src1] |
| Angular 18 | EOL | Full | Experimental zoneless change detection. Signal-based components. |
| Angular 19 | LTS (until May 2026) | Full | standalone: true by default. Stable signals API. [src7] |
| Angular 20 | LTS (until Nov 2026) | Full | Zoneless stable. Signal-based components mature. |
| Angular 21 | Active | Full | Zoneless by default. Zone.js removal recommended for new apps. |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Existing AngularJS app > 5,000 LOC with active development | App is < 5,000 LOC or in maintenance-only mode | Full rewrite in Angular, React, or Vue |
| Team has Angular/TypeScript skills or budget to train | Team has no TypeScript experience and no training budget | Consider React (gentler JS-first learning curve) |
| Business requires continuous feature delivery during migration | App can go into feature freeze for 3-6 months | Big-bang rewrite during freeze period |
| Shared services and state make incremental approach necessary | Frontend and backend are being rewritten simultaneously | Greenfield Angular project from scratch |
| AngularJS security patches are needed (EOL means no patches) | Using HeroDevs NES (Never-Ending Support) for AngularJS | Stay on AngularJS with commercial LTS support |
| You want to stay in the Angular ecosystem with familiar patterns | Team prefers a completely different paradigm | Migrate to React, Vue, or Svelte instead |
strict: true in tsconfig.json) is strongly recommended but may require significant type annotation effort when migrating untyped AngularJS JavaScript code. [src2]standalone: true for components. If your hybrid setup relies on NgModule, you may need to explicitly set standalone: false on some components during the migration, then switch to standalone after removing AngularJS. [src1]