Wanna see something cool? Check out Angular Spotify 🎧

Upgrade to Angular 20 from Angular 13 - Part 5: Angular 18 with Claude Code

We are back. In Part 4, we upgraded Jira Clone from Angular 16 to 17 using Claude Code. That was the big one: standalone components, new control flow syntax, standalone bootstrap. A lot of code changed, but the Angular CLI schematics did most of the heavy lifting.

Angular 18 is a different kind of upgrade. No new syntax. No big rewrite. Instead, this release is about finishing the cleanup that Angular 17 started. The standalone migration left behind six NgModule wrappers that were kept as lazy-loading entry points. Angular 18 gave us the reason and the tools to finally remove them all.

The other notable change: Quill v1 to v2. Not because Angular 18 requires it, but because ngx-quill 26+ (the only version that supports Angular 18) dropped support for Quill v1. So we had to upgrade both at the same time.

1. The approach

The approach

Same pattern as before. I gave Claude the previous blog posts, the implementation plan from Part 4, and the current state of the codebase. It generated a 20-task plan with two phases:

  1. Phase 1: Dependency upgrades - the same incremental pattern from Parts 1-4
  2. Phase 2: NgModule cleanup - remove all remaining NgModules, migrate to provideRouter() with standalone route configs

2. Phase 1: Dependency upgrades

Familiar territory by now. Run ng update, then resolve each dependency one at a time, commit after each step.

Package Before (v17) After (v18)
@angular/core ^17.3.12 ^18.2.14
@angular/cli ^17.3.17 ^18.2.21
@angular-builders/custom-webpack ^17.0.2 ^18.0.0
@angular-eslint/* 17.5.3 18.4.3
@angular/cdk ^17.3.10 ^18.2.14
ng-zorro-antd ^17.4.1 ^18.2.1
@ant-design/icons-angular ^17.0.0 ^18.0.0
ngx-quill ^24.0.5 ^26.0.10
quill ^1.3.7 ^2.0.3

TypeScript (~5.4.5) and zone.js (~0.14.10) were already compatible with Angular 18, so no changes needed there.

2.1 The Quill v2 migration

In Part 4, I specifically called out that we stayed on ngx-quill v24 to avoid the Quill v2 migration. That is over now. ngx-quill 26+ is the only version that supports Angular 18, and it requires Quill v2.

Quill v2 migration

The good news: our Quill usage is minimal. Two editor instances (issue description and create issue modal) with a standard toolbar config. No custom modules, no custom formats.

The only concrete change was in angular.json. Quill v2 does not ship quill.min.js - the minified bundle simply does not exist in the v2 distribution. So the scripts entry needed to change:

- "./node_modules/quill/dist/quill.min.js"
+ "./node_modules/quill/dist/quill.js"

The CSS files (quill.core.css and quill.snow.css) kept their paths. The toolbar configuration and <quill-editor> component API were fully compatible. No template or component changes needed.

After all dependency upgrades, npm install ran cleanly without --force. Build passed on the first try.

3. Phase 2: NgModule cleanup

This is the part that made Angular 18 worth writing about.

After the Angular 17 standalone migration, six NgModule files were left behind. They were not doing anything meaningful, they were just wrappers:

  • AppRoutingModule - wrapping RouterModule.forRoot(routes)
  • ProjectModule - importing standalone components and ng-zorro modules
  • ProjectRoutingModule - wrapping RouterModule.forChild(routes)
  • WorkInProgressModule - wrapping a single standalone component
  • WorkInProgressRoutingModule - wrapping a single route
  • JiraControlModule - re-exporting 6 standalone components as a barrel

The reason they survived Part 4: Angular’s lazy loading used loadChildren with module references, and the standalone schematics did not convert these automatically.

3.1 Replacing modules with route configs

The fix is straightforward. Instead of wrapping routes in a NgModule with RouterModule.forChild(), export a plain Routes array from a file. Then in the parent route config, use loadChildren to point at that file instead of the old module:

// Before: app-routing.module.ts
{
  path: 'project',
  loadChildren: () => import('./project/project.module').then(m => m.ProjectModule)
}

// After: app.routes.ts
{
  path: 'project',
  loadChildren: () => import('./project/project.routes').then(m => m.PROJECT_ROUTES)
}

The child route file itself becomes a simple export with no NgModule wrapper:

// Before: project-routing.module.ts
@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class ProjectRoutingModule {}

// After: project.routes.ts
export const PROJECT_ROUTES: Routes = [
  {
    path: '',
    component: ProjectComponent,
    providers: [
      importProvidersFrom(NzIconModule.forChild(NZ_JIRA_ICONS))
    ],
    children: [
      { path: 'board', component: BoardComponent },
      { path: 'settings', component: SettingsComponent },
      { path: `issue/:${ProjectConst.IssueId}`, component: FullIssueDetailComponent },
      { path: '', redirectTo: 'board', pathMatch: 'full' }
    ]
  }
];

The key insight: loadChildren in Angular 18 can resolve either a module or a plain Routes array. No RouterModule.forChild() boilerplate needed. The NzIconModule.forChild(NZ_JIRA_ICONS) is preserved via route-level providers, ensuring the ng-zorro icons are still registered when the project feature loads.

3.2 Replacing AppRoutingModule with provideRouter

In main.ts, the AppRoutingModule inside importProvidersFrom() was replaced with Angular’s provideRouter():

// Before
importProvidersFrom(BrowserModule, ReactiveFormsModule, AppRoutingModule, ...)

// After
provideRouter(appRoutes),
importProvidersFrom(NzIconModule.forRoot([]), AkitaNgRouterStoreModule, QuillModule.forRoot())

While we were at it, we also cleaned up main.ts by removing BrowserModule (automatically included by bootstrapApplication), ReactiveFormsModule (already imported by each standalone component that uses forms), and NzSpinModule (already imported directly in AppComponent).

4. The result

11 commits. Application compiles, builds, and lints without errors.

The final commit log:

600c2ef fix: provide NzDrawerService, NzModalService, NzNotificationService at route level
bd33060 refactor: remove all remaining NgModules, migrate to provideRouter and route configs
fd348fc fix: remove deprecated extractCss option from angular.json
abd295a build(deps): clean npm install with resolved Angular 18 dependencies
d1d7850 build(deps): upgrade ngx-quill to 26.x and quill to v2 for Angular 18
9d1620e build(deps): upgrade ng-zorro-antd to 18.2.1 and @ant-design/icons-angular to 18.0.0
42f5b54 build(deps): upgrade @angular/cdk to 18.2.14
33b0fe5 build(deps): upgrade @angular-eslint/* to 18.4.3
015104b build(deps): upgrade @angular-builders/custom-webpack to 18.0.0
4bba70a build(deps): ng update @angular/core@18 @angular/cli@18 --force
c8e299d docs: add Angular 18 upgrade implementation plan

The bundle size actually went down slightly: initial total dropped from 1.16 MB to 1.15 MB. The lazy chunk names changed from project-project-module to project-project-routes, confirming the module-free routing is working.

5. Source code

https://github.com/trungvose/jira-clone-angular/pull/111

6. The hidden gotcha: service providers

Everything compiled. The build passed. Then I opened the app and hit this:

NullInjectorError: No provider for NzDrawerService!

The hidden gotcha: service providers

This is the part that catches you if you are not careful. NgModules do two things: they declare/import components, and they register services. When ProjectModule imported NzDrawerModule, NzModalModule, and NzNotificationModule, those modules registered their services (NzDrawerService, NzModalService, NzNotificationService) in the module’s injector. Standalone components import the component directives directly, but the services still need a provider somewhere.

The fix was to add those modules to the route-level providers:

export const PROJECT_ROUTES: Routes = [
  {
    path: '',
    component: ProjectComponent,
    providers: [
      importProvidersFrom(
        NzIconModule.forChild(NZ_JIRA_ICONS),
        NzDrawerModule,
        NzModalModule,
        NzNotificationModule
      )
    ],
    // ...
  }
];

This is a good reminder that ng build passing does not mean the app works. The compiler cannot catch missing providers. Those are runtime errors. If you are removing NgModules, test the actual features that depend on injected services from those modules. The build will not save you here.

I had already merged the main PR (#111) before catching this, so it went out as a follow-up hotfix (#112). Fortunately I had not yet published this build on Netlify, so no users were affected.

Netlify deployment

7. What I learned

Angular 18 is the cleanup release. The hard architectural work was done in Angular 17. This release is about removing the leftover wrappers.

Angular 18 live

The NgModule removal was the most satisfying part. Those six module files were boilerplate that existed only because the lazy loading system used to require them. Replacing them with plain route config arrays made the codebase simpler and easier to understand. No more RouterModule.forChild() boilerplate, no more barrel re-export modules.

The Quill v1 to v2 migration was the biggest risk on paper, but turned out to be a non-issue. For basic toolbar usage, the API is compatible. The only breaking change we hit was a missing file (quill.min.js renamed to quill.js). If your project uses custom Quill modules or formats, your experience would likely be different.

One small thing worth noting: extractCss: true in angular.json is deprecated in Angular 18. The ng update schematic did not remove it automatically, but the build still passed. We removed it manually to stay clean.

8. What’s next

We are at Angular 18. The remaining path is 18 -> 19 -> 20. Angular 19 introduces the new signal-based forms and makes zoneless change detection more accessible. The codebase is now fully standalone with no NgModules, which puts us in a good position for the signal-based future.

See you in Part 6.

Published 14 Mar 2026

Read more

 — Upgrade to Angular 20 from Angular 13 - Part 4: Angular 17 with Claude Code
 — Upgrade to Angular 20 from Angular 13 - Part 3: Angular 16 with Claude Code
 — I added synced lyrics to Angular Spotify without writing a single line of code
 — Google TypeScript Style Guide
 — Typescript types vs interface