Routing and forms

Route configuration with lazy loading and functional guards, then typed reactive forms with validation that the user can act on.

Routes, lazy loading and guards

// app.routes.ts
import { Routes, Router, CanActivateFn } from '@angular/router'
import { inject } from '@angular/core'
import { AuthStore } from './auth.store'

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthStore)
  return auth.signedIn() ? true : inject(Router).createUrlTree(['/login'])
}

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent) },
  {
    path: 'guides/:id',
    loadComponent: () => import('./guide.component').then(m => m.GuideComponent)
  },
  {
    path: 'admin',
    canActivate: [authGuard],
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES)
  },
  { path: '**', loadComponent: () => import('./not-found.component').then(m => m.NotFoundComponent) }
]
// main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router'

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding(), withInMemoryScrolling({ scrollPositionRestoration: 'top' }))
  ]
})
  • loadComponent and loadChildren split routes into separate bundles, so the first screen does not download the admin section.
  • With withComponentInputBinding(), a route parameter lands in an input() of the same name instead of being read from a snapshot.
  • Functional guards use inject() and return a boolean or a UrlTree; returning a tree is cleaner than navigating imperatively.
  • withInMemoryScrolling restores scroll position on navigation, which users notice when it is missing.
  • '**' must be the last route — it is the wildcard that catches everything.

Typed reactive forms

import { Component, inject } from '@angular/core'
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'
import { JsonPipe } from '@angular/common'

@Component({
  selector: 'app-signup',
  imports: [ReactiveFormsModule, JsonPipe],
  templateUrl: './signup.component.html'
})
export class SignupComponent {
  private fb = inject(FormBuilder)

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(12)]],
    plan: ['free' as 'free' | 'pro', Validators.required]
  })

  submit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched()      // show errors on every control
      return
    }
    const value = this.form.getRawValue()   // fully typed payload
    this.save(value)
  }
}
<form [formGroup]="form" (ngSubmit)="submit()">
  <label for="email">Email</label>
  <input id="email" type="email" formControlName="email"
         [attr.aria-invalid]="form.controls.email.invalid && form.controls.email.touched" />

  @if (form.controls.email.touched && form.controls.email.hasError('email')) {
    <p class="error">Enter a valid email address.</p>
  }

  <button type="submit" [disabled]="form.invalid">Create account</button>
</form>
NeedApproach
A few simple fieldsFormsModule with ngModel
Typed model, async validators, dynamic rowsReactive forms with FormBuilder
Cross-field ruleA validator on the group, not on one control
Sync with a server checkAsync validator returning an error map or null
Repeatable block of fieldsFormArray
⚠️
Client-side validation is a courtesy, not a security control. Validate every payload again on the server, and design messages so the user knows which field to change rather than that something failed.
import { Component, inject, input, computed } from '@angular/core'
import { RouterLink, RouterOutlet } from '@angular/router'

@Component({
  selector: 'app-guide',
  imports: [RouterLink, RouterOutlet],
  template: `
    <h1>Guide {{ id() }}</h1>
    <a routerLink="/guides" [queryParams]="{ page: 2 }">Back to list</a>
    <router-outlet />
  `
})
export class GuideComponent {
  readonly id = input.required<string>()      // bound from the :id route parameter
  readonly router = inject(Router)
}
  • routerLink renders a real anchor, so middle-click and open-in-new-tab keep working; use Router.navigate only for programmatic redirects.
  • Prefer route inputs over reading a route snapshot: the component reacts when the parameter changes instead of being recreated.
  • A resolver or a route-level resource can load data before activation, but a loading state inside the component usually feels faster.

FAQ

Template-driven or reactive forms?
Reactive forms for anything with validation, typing or dynamic structure — the model is in the class, which makes it testable. Template-driven is fine for a one-field search box.
Why is my route parameter not updating?
You read route.snapshot once, so the value is captured at creation. Read the observable stream, or bind the parameter to a component input.

Services and dependency injection File-based routing and layouts

Last refreshed 2026-09-18.