Angular Standalone Migration skill for Codex

Retire NgModules one green build at a time. Codex runs the Angular control-flow and standalone schematics in the order that keeps the build green, committing after each mode, then finishes what they leave behind: forRoot modules become provide functions, class guards and interceptors become functions, feature modules become lazy route arrays with scoped providers, and TestBed setups import components. It also hunts down track $index on editable lists.

  • Frontend
  • $angular-standalone-migration
  • 177 lines
  • 2 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- angular-standalone-migration

Installs to ~/.agents/skills/angular-standalone-migration/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $angular-standalone-migration, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Migrates Angular apps from NgModules to standalone components and from *ngIf, *ngFor and *ngSwitch to built-in @if, @for and @switch control flow, using the official ng generate schematics in the right order - convert, prune modules, switch to bootstrapApplication - then lazy routes with loadComponent, functional guards and interceptors, provide* functions and TestBed updates. Use when upgrading an Angular codebase that still uses AppModule or feature modules, cleaning up SharedModule, adopting control flow syntax, or reviewing a standalone migration diff.

Use it when

  • An Angular codebase still bootstraps with AppModule or uses feature modules
  • Templates still use structural directives for if, for and switch
  • Reviewing a large schematic-generated migration diff
  • Turning feature modules into lazy-loaded routes

Use something else when

How to check the result

Production build and tests pass after every phase, rg finds no structural directives and only intentional NgModules, and lazy features appear as separate chunks.

The mistake it prevents

Accepting the migrated track $index on lists with editable rows, so inputs show stale values after an item is inserted or removed.

~/.agents/skills/angular-standalone-migration/SKILL.md

--- name: angular-standalone-migration description: Migrates Angular apps from NgModules to standalone components and from *ngIf, *ngFor and *ngSwitch to built-in @if, @for and @switch control flow,… license: MIT ---

Done well means: the app bootstraps with bootstrapApplication, no NgModule remains except where a third-party library forces one, every component imports exactly what its template uses, lazy routes load components or route arrays, templates use @if/@for/ @switch with meaningful track expressions, and the test suite passes at every step.

Inspect first

  1. Read AGENTS.md, package.json (@angular/core 17+ required for control flow; 19+ makes standalone: true the default), and angular.json.
  2. Inventory: rg -l "@NgModule" src | wc -l, rg -c "\*ngIf|\*ngFor|\*ngSwitch" src.
  3. Note modules that exist for providers or library setup (forRoot), not declarations; those become provide* calls, not standalone imports.
  4. Get a green baseline: ng build, ng test, e2e if present. Commit before each phase.

The schematics edit hundreds of files. Run each on a clean git tree and ask the user before starting if the tree is dirty.

Phase 1: control flow (independent, do it first or last)

ng generate @angular/core:control-flow            # prompts for path; use --path to scope

Review the diff for these patterns:

BeforeAfterCheck
*ngIf="user; else loading"@if (user) { ... } @else { ... }template ref removed if unused elsewhere
*ngIf="user$ | async as user"@if (user$ | async; as user) { ... }
*ngFor="let i of items; trackBy: trackById"@for (i of items; track trackById($index, i))simplify to track i.id
*ngFor="let i of items" (no trackBy)@for (i of items; track $index) or track ireplace with a stable id for editable or reordered lists
[ngSwitch] + *ngSwitchCase@switch (x) { @case (a) { } @default { } }no fallthrough
<ng-container *ngFor> wrappersremovedspacing/CSS that targeted wrappers

track $index on a list of editable rows makes inputs keep the wrong values after inserts or deletes. Fix those by hand. Add @empty { } where the old template had a separate “no items” *ngIf.

Phase 2: standalone conversion

ng generate @angular/core:standalone --mode=convert-to-standalone --path=src/app
# build + test, commit
ng generate @angular/core:standalone --mode=prune-ng-modules --path=src/app
# build + test, commit
ng generate @angular/core:standalone --mode=standalone-bootstrap
# build + test, commit

Run the three modes in this order. For large apps, scope --path to one feature folder at a time, starting from leaf features.

After convert-to-standalone, review:

  • Each component’s imports should list directives and pipes it uses (RouterLink, DatePipe, ReactiveFormsModule, child components). Remove CommonModule when the template uses only control flow and a few pipes; import those pipes individually.
  • SharedModule re-exporting everything: delete it; let each component import what it needs. A shared const array of imports is acceptable for truly universal sets.
  • Components that must stay in a module (library constraints) get standalone: false.

prune-ng-modules removes modules that only declared or re-exported things. It leaves modules with providers or bootstrap logic; finish those by hand (next section).

Phase 3: bootstrap and providers

// main.ts
bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }), // keep zone for now; see angular-signals
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([authInterceptor])),
    importProvidersFrom(LegacyChartsModule.forRoot({ theme: "dark" })), // library without provide* API
  ],
};
NgModule-eraStandalone
RouterModule.forRoot(routes)provideRouter(routes, ...features)
HttpClientModule + class interceptors via HTTP_INTERCEPTORSprovideHttpClient(withInterceptorsFromDi()) first, then functional withInterceptors([...])
BrowserAnimationsModuleprovideAnimationsAsync() (only if the app still uses @angular/animations)
StoreModule.forRoot (NgRx)provideStore(), provideEffects()
any XModule.forRoot() without a provide functionimportProvidersFrom(XModule.forRoot())
feature module providersroute-level providers on the lazy route
class guards implements CanActivateCanActivateFn using inject()
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

export const authGuard: CanActivateFn = () =>
  inject(AuthService).isLoggedIn() || inject(Router).createUrlTree(["/login"]);

Phase 4: lazy routes

export const routes: Routes = [
  { path: "", loadComponent: () => import("./home/home.component").then((m) => m.HomeComponent) },
  {
    path: "admin",
    canMatch: [authGuard],
    providers: [AdminApi],                         // scoped like the old feature module
    loadChildren: () => import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES),
  },
];

ng generate @angular/core:route-lazy-loading converts eagerly referenced standalone components in routes to loadComponent. Check the build output: each feature should be its own lazy chunk.

Phase 5: tests

  • TestBed: move components from declarations to imports: [MyComponent].
  • Replace HttpClientTestingModule with provideHttpClient(), provideHttpClientTesting().
  • Replace RouterTestingModule with provideRouter([]) and RouterTestingHarness where navigation is tested.
  • Override a standalone component’s imports with TestBed.overrideComponent(C, { set: { imports: [...] } }) to stub heavy children.

Optional follow-ups in the same spirit: ng generate @angular/core:inject (constructor injection to inject()), then signal migrations (see $angular-signals).

Gotchas

  • Provider scope changes: a service provided in an eagerly imported feature module was app-wide; moved to a lazy route’s providers, it becomes one instance per route injector. Services with providedIn: 'root' are unaffected.
  • Components declared in two modules (via different shared modules) get one import list; check both usage sites still render.
  • Entry components created dynamically (ViewContainerRef.createComponent) need their dependencies in their own imports; nothing else will provide them now.
  • APP_INITIALIZER multi-providers still work; on v19+ prefer provideAppInitializer(() => ...).
  • Library modules that declare components you use in templates: import the module (or its standalone exports) in each component’s imports.

Verification

ng build --configuration production   # compare chunk list and initial bundle size to baseline
ng test
npx playwright test                   # or the repo's e2e command
rg "@NgModule" src                    # should list only intentional leftovers
rg "\*ngIf|\*ngFor|\*ngSwitch" src    # should be empty after control flow migration

A production build catches missing template imports as errors ('x' is not a known element); fix each in the component’s imports, never with CUSTOM_ELEMENTS_SCHEMA.

Output

## Standalone migration: <scope>
- Phases done: control flow / convert / prune / bootstrap / lazy routes / tests
- NgModules remaining: <list with reason>
- Provider changes: <services whose scope changed>
- Lists switched from track $index to stable keys: <files>
- Bundle: initial <before> -> <after> KB; lazy chunks <n>
- Test and e2e status

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Angular Standalone Migration"
  short_description: "Retire NgModules one green build at a time"
  default_prompt: "$angular-standalone-migration Migrate our Angular app off AppModule to bootstrapApplication"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem