Introduction

Recently, I shared my experience migrating a large Angular application to standalone components in a Twitter thread. This sparked a lot of interest, so I expanded it into a more detailed guide. Migrating a complex Angular application to use standalone building blocks is a significant endeavor, and, in my experience, is not to be approached lightly. However, on the other hand, it is also beneficial to the project's code quality and the mental health of the developers involved (this is only half a joke).

In this article, I’ll provide a deeper dive into the process, including the steps I undertook, the challenges I encountered, and the solutions I implemented.

Application Overview

Let's start by first examining the application that I migrated:

  • Angular Version: 17 (must be Angular 15.2.0 or later).
  • Application domain: HR management system which incorporates lots of features like attendance tracking, leave management, hiring, integrations with external services like Microsoft Teams and Calendar and so on.
  • Structure: Over 1,000 interconnected components, directives, and pipes, 500+ NgModule-s.
  • Dependencies: Numerous external libraries and packages

I could talk a lot about the different complexities of this application. however, I think it will be easier to just show an excerpt from the package.json file:

"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, you are seeing it right: this project has 59 (!) dependencies. And all of them are used in lots of the components of the app.

So, naturally, I was scared to start the migration process. But, anyway, we did it, although, with some initial preparations.

Before Migrating

First, before we started, we performed three crucial steps:

  1. Run an initial test migration to see if it actually works. We rolled it back next and realized this is achievable.
  2. Inform the team that such a migration is being done and it might take up to several days. Instruct other devs to use standalone components when they author new ones during the development of their tasks.
  3. Update the branch to the latest version and search for all the NgModule instances in the project. This is not a very important step, however, it gave us idea of how much the schematic helped subsequently.

After doing this, we began the migration.

Initial Migration Steps

The migration began with the execution of Angular's official schematic, as detailed in the Angular documentation.

As instructed, we ran the following 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 automated process removed approximately 400 NgModule instances, marked components as standalone, and updated the bootstrap configuration in main.ts.

Here it is important to note that you have to run the schematic 3 times:

  1. First to mark everything as standalone and move those components/pipes/directives to the imports array of their respective modules instead of the declarations array.
  2. Next to remove the NgModules and move the now standalone components to direct imports of other standalone components where they are used.
  3. Finally, to remove the AppModule and use the standalone providers like provideRouter, ProvideHttpClient, etc.

After each run, we did a prod build locally to ensure nothing seriously broke (it did). After fixing the issues, we ran the schematic again.

Finally, after going through these steps, we were left with an application that... kinda worked. Not really, because afterward, the real challenges began. Let's explore those now.

Challenges and Solutions

Circular Dependencies

Post-migration, circular dependencies became a small issue. Thankfully, there is an easy fix. Leveraging Angular's forwardRef function. For an in-depth explanation, refer to this resource.

Service Providers

Services previously provided within specific modules needed adjustments. We used a global search-and-replace operation updated the @Injectable decorator to {providedIn: 'root'}, ensuring consistent service availability across the application.

Now, let us talk a bit about this, because it might cause some doubts among readers. Yes, it is, in fact, perfectly okay to mark all of your services as providedIn: 'root'.

One might wonder if this won't make the services initialize sooner than intended (for instance, a service might have been a part of a lazily loaded module and now became provided globally). However, the Angular DI system is smart enough to create service instances when they are requested, so this must not be an issue.

If we think about it, if SomeService is provided in SomeModule and injected for the first time in SomeComponent (which has to be a part of SomeModule by either being declared on it or exported to it to be able to inject SomeService), then removing SomeModule won't change anything, since we can easily refactor SomeComponent to itself become lazily-loaded and still be the first to inject SomeService.

Residual NgModule Instances

Certain modules, particularly the dreaded SharedModule-s, persisted after the schematic execution. Yes, the schematic does not remove all the NgModule-s. Usually, it does not remove modules that are imported by other modules repeatedly, because, as far as I understand, it cannot tell if it is safe to remove it (Component A in Module B might import Component C from Module D via a Shared Module which imports Module D and is then imported into Module B).

So, what can be done about it? Well, it turns out we only have two options: either to extensively investigate the application to remove all intermediary modules on-by-one, or just nuke all the remaining modules and deal with the fallout.

We took the second approach which, in retrospect, turned out to be correct. Yes, after removing so many NgModule-s in one go, we initially faced hundreds of errors in the build result. However, most of them could be fixed automatically. Some would be fixed manually, but still very quickly, because the build output would point us to the exact location of a missing import. So, I advise doing just that - this "manual" labor will take way shorter than you might imagine - took just 20 minutes for us.
shorter than you might imagine - took just 20 minutes for us.

Routing Configuration

The schematic did not automatically convert routing modules used for lazy loading. I manually:

  1. Renamed .routing.module.ts files to .routes.ts.
  2. Removed the NgModule decorator.
  3. Exported the routes directly.
  4. Updated lazy-loaded imports to match the new configuration.

This ensured that the routing system aligned with Angular’s standalone paradigm. Also, you can consider using AI to do this - the required steps are very clearly cut, and it is fairly easy to explain to a machine what to do. I would welcome the readers of this article to copy-paste the steps above into Copilot Editor Chat (or any other AI software you use for development) and share the results in the comments!

Runtime Issues and Resolutions

Directive Binding Syntax

A significant challenge involved directives bound without bracket syntax ([]). Let me show a small example of what I mean:

<!-- with brackets -->
<div [someDirective]="'someValue'"></div>
<!-- without brackets -->
<div someDirective="{{someValue}}"></div>

Angular failed to recognize such "bracketless" directives unless explicitly imported into the component's template, leading to runtime errors. This is quite a tricky problem, as you will not get build-time errors and need to navigate to the exact components where this "bad" directive binding is used (for further details on this issue, please consult this discussion).

So, how can it be fixed? Well, if you have extensive unit tests, you can run them and fix issues that way. However, this won't cover you 100%, and, in addition, migrating to standalone might cause issues with unit tests imports, so you might first need to fix them before you can take this approach.

Because this project did not have any unit tests (not my fault!), I took another approach and spent 10-15 minutes utilizing the help of some AI to conjure up a script that would:

  1. Accept a directive/component selector
  2. Search for all the instances of this selector in the project's .html files
  3. Filter out the corresponding .component.ts files that do not have this directive/component imported
  4. Report file paths to those components

This resulted in a very quick fix of all missing imports

To clarify, Angular's schematic did import necessary components, but only in 90-95% of cases. I am unsure why it missed some instances, however, you can use this script to easily fix the rest.

Here is 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);
  });

You can also find the script in a GitHub Gist here.

If this article helped you decide to start migrating your Angular app to standalone, I encourage you to copy this script and utilize it for a smoother transition.

Results

Now, I just want to sum up the results of the migration:

  • Time Spent:
    • On Migration itself: 2 not full day, around 10 hours total
    • On bug fixing after the migration: around 2 weeks, however, it is important to note that only around 10% of the bugs were actually related to the migration itself, and others were just other random issues. Most of the migration-related bugs were just more missing imports, super easy to fix
  • Performance gains: Migrating to standalone helped us improve the build time by switching to ESBuild, which was previously very difficult because of some issues several of our project's modules had. We couldn't figure those issues out, but with NgModule-s gone, we switched to ESBuild in literally 10 minutes and hugely reduced the time our pipelines ran.
  • Improved code architecture: Obviously the biggest gain, the project suffered from many messy interconnected bits which got simplified with standalone adoption.

Note: also consider adding the strictStandalone option in the tsconfig.json file to enforce authoring only standalone components in the future.

Conclusion

Migrating a large-scale Angular application to standalone components is a complex but very rewarding process. By anticipating potential challenges and applying targeted solutions, as outlined above, Angular developers can achieve a simpler and more maintainable codebase.

If you want to see the original discussion that inspired this article, check out my Twitter thread.

Small Promotion

Gg2RPJKWwAAHSId.png
My book, Modern Angular, is now in print! I spent a lot of time writing about every single new Angular feature from v12-v18, including enhanced dependency injection, RxJS interop, Signals, SSR, Zoneless, and way more.

If you work with a legacy project, I believe my book will be useful to you in catching up with everything new and exciting that our favorite framework has to offer. Check it out here: https://www.manning.com/books/modern-angular

P.S: Check out Chapter 2 of my book, "A Standalone Future", to learn about standalone architecture, APIs, and more details on the migration process ;)


Tagged in:

Articles

Last Update: January 29, 2025