Introduction
Earlier, I documented my journey of migrating a substantial Angular application to standalone components in a post on X. The response was significant, prompting me to write a more comprehensive account. Transitioning a complex Angular codebase to standalone building blocks is a major undertaking that should not be underestimated. That said, it also brings tangible improvements to code quality and developer well-being (only slightly exaggerated).
Here, I offer a more thorough walkthrough covering the steps taken, the obstacles hit, and the fixes applied.
Application Overview
First, let's look at the application in question:
- Angular Version: 17 (requires Angular 15.2.0 or newer).
- Application domain: HR management platform featuring attendance tracking, leave management, recruiting, and integrations with tools such as Microsoft Teams and Calendar.
- Structure: More than 1,000 interconnected components, directives, and pipes, along with over 500
NgModuledefinitions. - Dependencies: A wide array of external libraries and packages.
The complexity is hard to convey in words, so let's just look at a snippet from the package.json:
"dependencies": {
"@angular/animations": "^17.3.11",
"@angular/cdk": "^16.2.14",
"@angular/common": "^17.3.11",
"@angular/compiler": "^17.3.11",
"@angular/core": "^17.3.11",
"@angular/forms": "^17.3.11",
"@angular/localize": "^17.3.11",
"@angular/material": "^16.2.0",
"@angular/platform-browser": "^17.3.11",
"@angular/platform-browser-dynamic": "^17.3.11",
"@angular/platform-server": "^17.3.11",
"@angular/router": "^17.3.11",
"@angular/service-worker": "^17.3.11",
"@auth0/angular-jwt": "^5.0.2",
"@azure/msal-angular": "^3.0.8",
"@azure/msal-browser": "^3.5.0",
"@babel/polyfill": "^7.12.1",
"@ckeditor/ckeditor5-angular": "^8.0.0",
"@kolkov/angular-editor": "^2.0.0",
"@microsoft/applicationinsights-web": "^2.6.5",
"@microsoft/signalr": "^8.0.0",
"@ng-select/ng-select": "^12.0.7",
"@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0",
"@raiser/raiser-integration": "17.0.0-rc.1",
"@swimlane/ngx-charts": "20.5.0",
"@swimlane/ngx-datatable": "^20.1.0",
"ajv": "^8.12.0",
"angular-cropperjs": "^14.0.1",
"bootstrap": "^4.0.0",
"chart.js": "^4.4.0",
"chartjs-plugin-stacked100": "^1.5.3",
"chartjs-plugin-zoom": "^2.0.1",
"ckeditor5": "^42.0.2",
"core-js": "^3.16.2",
"cropperjs": "^1.6.1",
"d3": "^7.0.0",
"dayjs": "^1.11.11",
"file-saver": "2.0.5",
"font-awesome-scss": "^1.0.0",
"hammerjs": "^2.0.8",
"jsplumb": "^2.15.6",
"ng-click-outside2": "^15.0.1",
"ng2-file-upload": "^5.0.0",
"ngx-bar-rating": "^7.0.1",
"ngx-clipboard": "^16.0.0",
"ngx-color-picker": "^14.0.0",
"ngx-drag-scroll": "^17.0.1",
"ngx-ellipsis": "^4.1.3",
"ngx-image-cropper": "^1.3.8",
"ngx-infinite-scroll": "^17.0.1",
"ngx-mask": "^15.2.1",
"ngx-scrollbar": "^13.0.3",
"ngx-ui-switch": "^14.1.0",
"powerbi-client-angular": "^3.0.5",
"primeicons": "^7.0.0",
"primeng": "^17.18.0",
"rxjs": "^7.4.0",
"tslib": "^2.3.1",
"zone.js": "^0.14.7"
},
Yes, that's right: this project has 59 (!) dependencies, all actively used throughout many components.
Understandably, starting this migration felt intimidating. Yet we proceeded, albeit after some groundwork.
Before Migrating
Prior to diving in, we completed three essential tasks:
- Ran a trial migration to confirm feasibility, then reverted it to establish confidence.
- Informed the team about the ongoing migration and its expected duration (several days), advising them to use standalone components for any new code written during this time.
- Updated the branch and searched for all
NgModuledefinitions to gauge the scope and to see how much the schematic would help.
With this prep, we launched the migration.
Initial Migration Steps
We kicked off using Angular's official schematic, as outlined in the Angular documentation.
As per the instructions, we executed these commands:
ng g @angular/core:standalone # and select "Convert all components, directives and pipes to standalone"
ng g @angular/core:standalone # and select "Remove unnecessary NgModule classes"
ng g @angular/core:standalone # and select "Bootstrap the project using standalone APIs"
This automatic process eliminated around 400 NgModule instances, flagged components as standalone, and adjusted the bootstrap configuration in main.ts.
Be aware that the schematic needs to be run three times:
- First, to mark everything as standalone and shift components/pipes/directives from the
declarationsarray to theimportsarray of their respective modules. - Next, to strip out the
NgModulewrappers and place the standalone pieces into direct imports where they are consumed. - Finally, to remove
AppModuleand switch to standalone providers likeprovideRouterandProvideHttpClient.
After each pass, we ran a production build locally to catch obvious breakage (and there was some). After resolving these issues, we moved on to the next run.
After all this, we had an application that... mostly worked. In reality, it didn't fully, because the hard part came next. Let's dig into that.
Challenges and Solutions
Circular Dependencies
One post-migration issue was the appearance of circular dependency problems. The fix is straightforward: use Angular's forwardRef function. For a thorough explanation, check this guide.
Service Providers
Services that were once scoped to a specific module needed adjustments. A global find-and-replace was used to switch the @Injectable decorator to {providedIn: 'root'}, making services available app-wide.
Let's delve into this, as it may raise questions. Yes, marking every service as providedIn: 'root' is entirely acceptable.
Some might worry about services initializing sooner than before (for instance, a service originally tied to a lazy-loaded module). However, Angular's DI system is designed to create instances on first request, so this shouldn't be a concern.
Consider this: if SomeService is provided in SomeModule and injected first by SomeComponent (which must belong to SomeModule either through declaration or export), then removing SomeModule doesn't alter anything. We can simply make SomeComponent lazy-loaded itself, and still have it be the first to inject SomeService.
Residual NgModule Instances
Some modules, especially the infamous SharedModule, survived the schematic. The tool doesn't remove every NgModule, particularly those that are widely re-imported, as it cannot always determine safety (e.g., Component A in Module B imports Component C from Module D via a chain of shared modules).
What to do? You have two paths: carefully dismantle each leftover module one by one, or delete them all and handle the consequences.
We chose the latter, and it proved correct in hindsight. After deleting all those NgModules, we faced a mountain of build errors. However, most were auto-fixable. The rest were quickly resolved manually, guided by the build output pointing to the exact missing imports. This "manual" work took only 20 minutes — much faster than expected.
Routing Configuration
The schematic left lazy-loaded routing modules untouched. So, I manually:
- Renamed
.routing.module.tsfiles to.routes.ts. - Removed the
NgModuledecorator. - Exported the routes directly.
- Updated lazy-loaded imports to fit the new setup.
This aligned the routing with the standalone approach. You might also try using AI for this — the steps are clear-cut and easy to describe. I encourage you to paste these steps into Copilot Editor Chat (or any similar tool) and share your results!
Runtime Issues and Resolutions
Directive Binding Syntax
A notable hurdle was directives used without bracket syntax ([]). Here's a tiny example:
<!-- with brackets -->
<div [someDirective]="'someValue'"></div>
<!-- without brackets -->
<div someDirective="{{someValue}}"></div>
Angular wouldn't recognize these "bracketless" directives unless they were explicitly imported into the component's template, which caused runtime failures. This is tricky because you get no compile-time warnings and must manually hunt for the offending usages (for more on this, see this thread).
How to fix? If you have solid unit tests, run them and address the failures. But this isn't foolproof, and the migration might first require fixing test imports before you can rely on this method.
Since my project had no unit tests (not my choice!), I spent 10-15 minutes having an AI craft a script that would:
- Accept a directive/component selector
- Search for all occurrences of that selector in
.htmlfiles - Identify
.component.tsfiles that lack the proper import - Output the file paths needing fixes
This made fixing the missing imports quite quick.
For clarity, the schematic imported the necessary components, but only in 90-95% of cases. The reason for the gaps is unclear, but this script handles the rest efficiently.
Here's the script:
const fs = require('fs');
const path = require('path');
async function findMissingImports(projectRoot, directiveName, directiveClass) {
const missingImportFiles = [];
const searchDirectory = async (dir) => {
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
await searchDirectory(filePath); // Recursive call for subdirectories
} else if (file.endsWith('.component.html')) {
const tsFilePath = filePath.replace('.component.html', '.component.ts');
// Check if the corresponding .ts file exists
if (!fs.existsSync(tsFilePath)) continue;
// 1. Check if the directive is used in the HTML file
const htmlContent = fs.readFileSync(filePath, 'utf-8');
const directiveUsed = htmlContent.includes(directiveName);
if (!directiveUsed) continue;
// 2. Check if the directive class is imported in the .ts file
const tsContent = fs.readFileSync(tsFilePath, 'utf-8');
const importRegex = new RegExp(`import\\s+\\{.*\\b${directiveClass}\\b.*\\}\\s+from\\s+.*`);
const importPresent = importRegex.test(tsContent);
// 3. If the directive is used but the import is missing, add to the list
if (directiveUsed && !importPresent) {
missingImportFiles.push(tsFilePath);
}
}
}
};
await searchDirectory(projectRoot);
return missingImportFiles;
}
// --- Configuration ---
const PROJECT_ROOT = './'; // Replace with your project path
const DIRECTIVE_NAME = "someDirective";
const DIRECTIVE_CLASS = "SomeDirective";
// --- Run the script ---
findMissingImports(PROJECT_ROOT, DIRECTIVE_NAME, DIRECTIVE_CLASS)
.then((missingFiles) => {
if (missingFiles.length > 0) {
console.log('Component files missing the import:');
missingFiles.forEach((file) => console.log(file));
} else {
console.log('No component files found missing the import.');
}
})
.catch((err) => {
console.error('An error occurred:', err);
});
A copy is also available as a GitHub Gist here.
If this article inspires you to start your own migration, feel free to use this script for a smoother ride.
Results
Here's a summary of what we achieved:
- Time Spent:
- On the migration itself: Around 10 hours spread over two days.
- On post-migration fixes: About 2 weeks, though only about 10% of those bugs were migration-related. The rest were pre-existing issues. Most migration bugs were simply missing imports, which were quick to resolve.
- Performance gains: The switch to standalone enabled us to adopt ESBuild, which had previously been blocked by issues in some modules. Once the
NgModules were gone, we switched to ESBuild in about 10 minutes, dramatically cutting pipeline times. - Improved code architecture: The most significant benefit. The project's convoluted dependencies were greatly simplified by embracing standalone patterns.
Note: consider enabling the
strictStandaloneoption intsconfig.jsonto enforce standalone-only code in the future.
Conclusion
Migrating a large Angular application to standalone is challenging but highly rewarding. By anticipating the hurdles and applying the right fixes, as detailed here, developers can end up with a leaner, more maintainable codebase.
For the original discussion, see my Twitter post.
Small Promotion

My book, Modern Angular, is now available in print! I've written extensively about every new Angular feature from v12 to v18, including enhanced DI, RxJS interop, Signals, SSR, Zoneless, and much more.
If you're maintaining a legacy project, this book will help you catch up on all the exciting developments in our favorite framework. Get it here: https://www.manning.com/books/modern-angular
P.S: Don't miss Chapter 2, "A Standalone Future," for deeper insights into the standalone architecture, APIs, and migration details ;)

