Components

Migrating a Large Angular Application to Standalone

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 en

Migrating a Large Angular Application to Standalone — Components article by Armen Vardanyan on Angular In Depth
Migrating a Large Angular Application to Standalone — Components article by Armen Vardanyan on Angular In Depth
On this page · 14 sections

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 NgModule definitions.
  • 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:

  1. Ran a trial migration to confirm feasibility, then reverted it to establish confidence.
  2. 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.
  3. Updated the branch and searched for all NgModule definitions 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:

  1. First, to mark everything as standalone and shift components/pipes/directives from the declarations array to the imports array of their respective modules.
  2. Next, to strip out the NgModule wrappers and place the standalone pieces into direct imports where they are consumed.
  3. Finally, to remove AppModule and switch to standalone providers like provideRouter and ProvideHttpClient.

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:

  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 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:

  1. Accept a directive/component selector
  2. Search for all occurrences of that selector in .html files
  3. Identify .component.ts files that lack the proper import
  4. 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 strictStandalone option in tsconfig.json to 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

Gg2RPJKWwAAHSId.png
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 ;)


Migrating a Large Angular Application to Standalone — figure 2

Tagged in:

Articles

Last Update: January 29, 2025

AV
Armen Vardanyan

Writes about RxJS, State, Dependency Injection. Active 2019–2026.

All 57 articles →