lunx.docs
DocsFrameworksAngular

Angular Adapter

Angular 17+ with Signals, standalone components, lazy-loaded routes and RxJS. Production builds are compiled ahead of time by your project's Angular compiler, as ng build does.

Angular 17+ · Ivy · Signals
Ahead of time

lunx build runs @angular/compiler-cli (ngtsc), Angular's linker and Angular's build optimizations: no runtime template compiler, bundles the size of ng build's.

Signals

Angular 17 Signals API (signal, computed, effect) for fine-grained reactivity without Zone.js overhead.

Standalone components

No NgModule required. Standalone components, directives, and pipes with direct imports.

RxJS compatible

Full RxJS 7+ support. Observable-based data fetching, event streams, and state management.

Fast dev

lunx dev compiles components at runtime (JIT), with templateUrl / styleUrl inlined and SCSS, Less or Stylus styles compiled.

Lazy routes

loadComponent() and loadChildren() produce separate async chunks for route-level code splitting.

Setup

bash
npx lunx-dev@latest create my-angular-app --template angular --ts
cd my-angular-app && npm install && npm run dev
typescriptlunx.config.ts
import { defineConfig } from 'lunx-dev'

export default defineConfig({
  framework: 'angular',
})

Standalone component with Signals

typescriptsrc/app/components/counter.component.ts
import { Component, signal, computed, effect } from '@angular/core'
import { CommonModule } from '@angular/common'

@Component({
  selector:    'app-counter',
  standalone:  true,
  imports:     [CommonModule],
  template: `
    <div class="counter">
      <h2>Count: {{ count() }}</h2>
      <p>Doubled: {{ doubled() }}</p>
      <div class="controls">
        <button (click)="decrement()">−</button>
        <button (click)="increment()">+</button>
        <button (click)="reset()" [disabled]="count() === 0">Reset</button>
      </div>
    </div>
  `,
  styles: [`
    .counter  { display: flex; flex-direction: column; gap: 1rem; padding: 1.5rem; }
    .controls { display: flex; gap: 0.5rem; }
    button    { padding: 0.5rem 1rem; border-radius: 8px; cursor: pointer; }
    button:disabled { opacity: 0.4; cursor: not-allowed; }
  `],
})
export class CounterComponent {
  count   = signal(0)
  doubled = computed(() => this.count() * 2)

  constructor() {
    // Runs whenever count changes — like useEffect
    effect(() => {
      console.log(`Count changed to ${this.count()}`)
    })
  }

  increment() { this.count.update(n => n + 1) }
  decrement() { this.count.update(n => n - 1) }
  reset()     { this.count.set(0) }
}

Standalone app bootstrap

typescriptsrc/main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import { provideRouter }        from '@angular/router'
import { provideHttpClient }    from '@angular/common/http'
import { AppComponent }         from './app/app.component'
import { routes }               from './app/app.routes'

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
  ],
}).catch(console.error)

Lazy-loaded routes

typescriptsrc/app/app.routes.ts
import { Routes } from '@angular/router'

export const routes: Routes = [
  {
    path:            '',
    loadComponent:   () => import('./pages/home.component').then(m => m.HomeComponent),
  },
  {
    path:            'dashboard',
    loadComponent:   () => import('./pages/dashboard.component').then(m => m.DashboardComponent),
    canActivate:     [() => inject(AuthService).isLoggedIn()],
  },
  {
    path:            'admin',
    loadChildren:    () => import('./admin/admin.routes').then(m => m.adminRoutes),
  },
  {
    path:            '**',
    loadComponent:   () => import('./pages/not-found.component').then(m => m.NotFoundComponent),
  },
]

HTTP data fetching with rxjs

typescriptsrc/app/services/users.service.ts
import { Injectable, inject } from '@angular/core'
import { HttpClient }         from '@angular/common/http'
import { Observable }         from 'rxjs'
import { catchError, map }    from 'rxjs/operators'

export interface User {
  id:    number
  name:  string
  email: string
}

@Injectable({ providedIn: 'root' })
export class UsersService {
  private http = inject(HttpClient)
  private base = import.meta.env.LUNX_PUBLIC_API_URL

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(`${this.base}/users`).pipe(
      map(users => users.sort((a, b) => a.name.localeCompare(b.name))),
      catchError(err => { console.error(err); throw err })
    )
  }

  getUserById(id: number): Observable<User> {
    return this.http.get<User>(`${this.base}/users/${id}`)
  }
}

Production builds

With @angular/compiler-cli installed (the starter has it), lunx buildcompiles the app ahead of time with the project's Angular compiler, finishes partially compiled libraries with Angular's linker and runs Angular's build optimizations, so @angular/compileris not shipped. The starter's bundle is 31.7 kB gzip against 31.9 kB from ng build, built in 3.8 s cold against 4.9 s. It readstsconfig.app.json, or tsconfig.json.

bash
npm install -D @angular/compiler-cli

Without it, the build compiles at runtime (JIT) and ships the template compiler (about 250 kB gzip), and lunx says so.

Zone.js and Signals
Angular supports running without Zone.js when Signals drive change detection. The starter bootstraps with provideZonelessChangeDetection() and does not install Zone.js.