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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- angular-standalone-migration Installs to ~/.agents/skills/angular-standalone-migration/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install angular-standalone-migration The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project angular-standalone-migration Installs to .agents/skills/angular-standalone-migration/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/angular-standalone-migration.zipunzip -o angular-standalone-migration.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) angular-standalone-migration PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- Moving inputs, outputs and state to signals or going zoneless: use angular-signals
- General framework version bumps across many packages: use dependency-upgrades
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.
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
- Read
AGENTS.md,package.json(@angular/core17+ required for control flow; 19+ makesstandalone: truethe default), andangular.json. - Inventory:
rg -l "@NgModule" src | wc -l,rg -c "\*ngIf|\*ngFor|\*ngSwitch" src. - Note modules that exist for providers or library setup (
forRoot), not declarations; those becomeprovide*calls, not standalone imports. - 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:
| Before | After | Check |
|---|---|---|
*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 i | replace with a stable id for editable or reordered lists |
[ngSwitch] + *ngSwitchCase | @switch (x) { @case (a) { } @default { } } | no fallthrough |
<ng-container *ngFor> wrappers | removed | spacing/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
importsshould list directives and pipes it uses (RouterLink,DatePipe,ReactiveFormsModule, child components). RemoveCommonModulewhen the template uses only control flow and a few pipes; import those pipes individually. SharedModulere-exporting everything: delete it; let each component import what it needs. A sharedconstarray 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-era | Standalone |
|---|---|
RouterModule.forRoot(routes) | provideRouter(routes, ...features) |
HttpClientModule + class interceptors via HTTP_INTERCEPTORS | provideHttpClient(withInterceptorsFromDi()) first, then functional withInterceptors([...]) |
BrowserAnimationsModule | provideAnimationsAsync() (only if the app still uses @angular/animations) |
StoreModule.forRoot (NgRx) | provideStore(), provideEffects() |
any XModule.forRoot() without a provide function | importProvidersFrom(XModule.forRoot()) |
feature module providers | route-level providers on the lazy route |
class guards implements CanActivate | CanActivateFn 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
declarationstoimports: [MyComponent]. - Replace
HttpClientTestingModulewithprovideHttpClient(), provideHttpClientTesting(). - Replace
RouterTestingModulewithprovideRouter([])andRouterTestingHarnesswhere 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 withprovidedIn: '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 ownimports; nothing else will provide them now. APP_INITIALIZERmulti-providers still work; on v19+ preferprovideAppInitializer(() => ...).- 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.yaml
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.