Phase 7 — Forms

Chapter 1 — Why Forms Are a Big Deal


1.1 — Forms Are Everywhere

Every meaningful interaction a user has with a web application goes through a form. Login, registration, search, checkout, writing a post, updating a profile, placing an order, leaving a review — all of these are forms.

A form is not just an HTML <form> tag. A form is a system. It needs to collect user input, validate that input, show meaningful error messages at the right time, handle the loading state while submitting, and deal with success and failure responses.

Getting forms right makes the difference between an app that feels professional and one that feels broken. When a user fills out 10 fields and hits submit, sees a generic error message with no indication of what went wrong, and then realizes all their data was wiped — that is a bad form experience. Angular gives you the tools to make forms that are a pleasure to use.


1.2 — Two Approaches to Forms in Angular

Angular provides two completely different ways to build forms. They are not variations of the same thing — they are genuinely different approaches with different strengths.

Template-Driven Forms — You describe the form almost entirely in the HTML template using directives. Angular reads your template and builds the form model behind the scenes for you. This approach is simpler and faster to set up for basic forms. Less TypeScript code. The logic lives in the template.

Reactive Forms — You build the form model explicitly in TypeScript. The template then binds to that model. This approach is more powerful, more predictable, easier to test, and better for complex forms. More TypeScript code, but more control.

The rule of thumb is:

Use Template-Driven Forms for simple forms — a contact form, a newsletter signup, a basic login with two fields.

Use Reactive Forms for anything complex — multi-step forms, forms with dynamic fields, forms that depend on API data, forms that need complex validation.

In real applications, you will mostly use Reactive Forms. But understanding both is important because you will encounter both in existing codebases.


Chapter 2 — Template-Driven Forms


2.1 — Setting Up FormsModule

Template-driven forms require FormsModule. Import it directly in any component that uses template-driven forms:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css'
})
export class Contact {

}

2.2 — Your First Template-Driven Form

Let's build a complete contact form step by step.

src/app/contact/contact.ts:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css'
})
export class Contact {

  name: string = '';
  email: string = '';
  subject: string = '';
  message: string = '';
  isSubmitted: boolean = false;

  onSubmit(): void {
    console.log('Form submitted!');
    console.log('Name:', this.name);
    console.log('Email:', this.email);
    console.log('Subject:', this.subject);
    console.log('Message:', this.message);
    this.isSubmitted = true;
  }
}

src/app/contact/contact.html:

<div class="form-container">

  @if (isSubmitted) {
    <div class="success-message">
      <h2>✓ Message Sent!</h2>
      <p>Thank you for reaching out. We will get back to you soon.</p>
    </div>
  } @else {

    <h1>Contact Us</h1>

    <form #contactForm="ngForm" (ngSubmit)="onSubmit()">

      <div class="field">
        <label for="name">Your Name</label>
        <input
          id="name"
          type="text"
          name="name"
          [(ngModel)]="name"
          required
          minlength="2"
          placeholder="Rahul Sharma"
          #nameField="ngModel">

        @if (nameField.invalid && nameField.touched) {
          <div class="error">
            @if (nameField.errors?.['required']) {
              <span>Name is required</span>
            }
            @if (nameField.errors?.['minlength']) {
              <span>Name must be at least 2 characters</span>
            }
          </div>
        }
      </div>

      <div class="field">
        <label for="email">Email Address</label>
        <input
          id="email"
          type="email"
          name="email"
          [(ngModel)]="email"
          required
          email
          placeholder="rahul@example.com"
          #emailField="ngModel">

        @if (emailField.invalid && emailField.touched) {
          <div class="error">
            @if (emailField.errors?.['required']) {
              <span>Email is required</span>
            }
            @if (emailField.errors?.['email']) {
              <span>Please enter a valid email address</span>
            }
          </div>
        }
      </div>

      <div class="field">
        <label for="subject">Subject</label>
        <input
          id="subject"
          type="text"
          name="subject"
          [(ngModel)]="subject"
          required
          placeholder="How can we help?"
          #subjectField="ngModel">

        @if (subjectField.invalid && subjectField.touched) {
          <div class="error">
            <span>Subject is required</span>
          </div>
        }
      </div>

      <div class="field">
        <label for="message">Message</label>
        <textarea
          id="message"
          name="message"
          [(ngModel)]="message"
          required
          minlength="10"
          rows="5"
          placeholder="Write your message here..."
          #messageField="ngModel">
        </textarea>

        @if (messageField.invalid && messageField.touched) {
          <div class="error">
            @if (messageField.errors?.['required']) {
              <span>Message is required</span>
            }
            @if (messageField.errors?.['minlength']) {
              <span>Message must be at least 10 characters</span>
            }
          </div>
        }
      </div>

      <button
        type="submit"
        [disabled]="contactForm.invalid">
        Send Message
      </button>

    </form>

  }

</div>

There is a lot happening here. Let's understand each concept one by one.


2.3 — Understanding the Key Pieces

#contactForm="ngForm"

This is a template reference variable. When you write #contactForm="ngForm" on the <form> element, Angular creates an NgForm object representing the entire form and stores it in contactForm. You can then use contactForm anywhere in the template.

contactForm.invalid is true when any field in the form fails validation. That is why the submit button becomes disabled when the form is invalid — [disabled]="contactForm.invalid".

contactForm.valid, contactForm.dirty, contactForm.touched, contactForm.value — these are all properties available on the form reference.


name="name" on every input

Every input that participates in a template-driven form must have a name attribute. Angular uses the name attribute to register the field with the form. Without it, the field is invisible to Angular's form system.


[(ngModel)]="name"

This is two-way binding connecting the input to your TypeScript property. When the user types, this.name updates. When this.name changes in TypeScript, the input value updates.


required, minlength="2", email

These are Angular's built-in validators written directly as HTML attributes. required marks the field as mandatory. minlength sets the minimum character count. email validates that the value looks like an email address.

Behind the scenes, Angular reads these attributes and adds validation logic to the field.


#nameField="ngModel"

Just like #contactForm="ngForm" gives you the whole form, #nameField="ngModel" gives you an NgModel object for this specific field. This object has properties you can use to show or hide error messages.

The most important properties are:

nameField.valid — is this field currently passing all validators?

nameField.invalid — is this field currently failing any validator?

nameField.touched — has the user ever clicked into this field and then clicked away?

nameField.dirty — has the user typed anything in this field?

nameField.pristine — has the user NOT typed anything yet?

nameField.errors — an object containing all current validation errors.


Why touched matters for showing errors

You never want to show error messages the moment the page loads — before the user has even interacted with the form. That would be terrible UX. You only want to show errors after the user has interacted with a field and it is invalid.

touched becomes true after the user focuses a field and then moves focus away from it. So nameField.invalid && nameField.touched means "this field is invalid AND the user has already interacted with it" — the perfect condition for showing an error.


nameField.errors?.['required']

nameField.errors is an object like { required: true } when required validation fails, or { minlength: { requiredLength: 2, actualLength: 1 } } when minlength fails, or null when there are no errors.

The ?. is optional chaining — it safely accesses the property without throwing an error if errors is null.


src/app/contact/contact.css:

.form-container {
  max-width: 600px;
  margin: 48px auto;
  padding: 0 24px;
}

h1 {
  font-size: 32px;
  font-weight: 700;
  color: #1a1a2e;
  margin-bottom: 32px;
}

.field {
  margin-bottom: 22px;
}

label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: #555;
  margin-bottom: 7px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

input, textarea {
  width: 100%;
  padding: 11px 14px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 15px;
  color: #333;
  transition: border-color 0.2s;
  font-family: inherit;
  box-sizing: border-box;
}

input:focus, textarea:focus {
  outline: none;
  border-color: #0070f3;
  box-shadow: 0 0 0 3px rgba(0,112,243,0.1);
}

input.ng-invalid.ng-touched,
textarea.ng-invalid.ng-touched {
  border-color: #dc3545;
}

input.ng-valid.ng-touched,
textarea.ng-valid.ng-touched {
  border-color: #28a745;
}

.error {
  margin-top: 6px;
  font-size: 13px;
  color: #dc3545;
}

button {
  width: 100%;
  padding: 13px;
  background: #0070f3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

button:hover { background: #005ac1; }

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.success-message {
  text-align: center;
  padding: 64px 24px;
}

.success-message h2 {
  font-size: 28px;
  color: #28a745;
  margin-bottom: 12px;
}

.success-message p {
  color: #666;
  font-size: 16px;
}

Notice input.ng-invalid.ng-touched and input.ng-valid.ng-touched in the CSS. Angular automatically adds CSS classes to every form field based on its current state:

ng-valid — field passes all validators ng-invalid — field fails at least one validator ng-touched — user has interacted with field ng-untouched — user has not yet interacted with field ng-dirty — user has changed the value ng-pristine — user has not yet changed the value

You can style these classes directly in your CSS to visually indicate field states — red border for invalid, green for valid.


2.4 — Resetting the Form

To reset all fields back to empty after submission:

import { Component, ViewChild } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  templateUrl: './contact.html',
  styleUrl: './contact.css'
})
export class Contact {

  @ViewChild('contactForm') contactForm!: NgForm;

  name: string = '';
  email: string = '';
  message: string = '';

  onSubmit(): void {
    console.log('Submitted:', { name: this.name, email: this.email, message: this.message });

    // Reset the form — clears values AND resets touched/dirty state
    this.contactForm.reset();
  }
}

NgForm.reset() clears all field values AND resets the form's state — touched, dirty, and invalid all go back to their initial state. This is important because if you just reset the TypeScript properties without calling .reset(), the form still thinks the fields were touched and would still show validation errors.


Chapter 3 — Reactive Forms


3.1 — The Philosophy of Reactive Forms

Reactive Forms are Angular's more powerful, more explicit forms approach. Instead of Angular reading your HTML template and building a form model from it, YOU build the form model in TypeScript and then connect the template to it.

This gives you:

Full control — you define every field, every validator, every initial value in TypeScript. There is no magic happening behind the scenes.

Type safety — because the form model is in TypeScript, you get autocomplete, type checking, and refactoring support.

Testability — you can test form logic without any DOM at all because the logic is in TypeScript.

Dynamic forms — adding and removing form fields at runtime is straightforward because you are working with objects in TypeScript.

Reactive — the form model is observable. You can subscribe to changes in specific fields or the entire form and react to them.


3.2 — Setting Up ReactiveFormsModule

Import ReactiveFormsModule in any component that uses reactive forms:

import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

}

3.3 — The Three Building Blocks of Reactive Forms

Before building anything, you need to understand the three classes that make up a reactive form:

FormControl — represents a single form field. It holds the current value, the validation state, and the validators for that one field.

import { FormControl, Validators } from '@angular/forms';

const emailControl = new FormControl('', [Validators.required, Validators.email]);
// First arg: initial value
// Second arg: array of validators

FormGroup — represents a group of form controls. A login form is a FormGroup with an email control and a password control. The group itself has a combined validity — the group is only valid if all its controls are valid.

import { FormGroup, FormControl, Validators } from '@angular/forms';

const loginForm = new FormGroup({
  email: new FormControl('', [Validators.required, Validators.email]),
  password: new FormControl('', [Validators.required, Validators.minLength(8)])
});

FormArray — represents a list of form controls or form groups. Used when you need a dynamic number of fields — like adding multiple phone numbers, or a list of education entries. We cover this in Chapter 5.


3.4 — FormBuilder — The Cleaner Syntax

Creating forms with new FormControl() and new FormGroup() everywhere gets verbose quickly. Angular provides FormBuilder — a service that lets you create the same form with a much cleaner syntax:

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

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

  private fb = inject(FormBuilder);

  // Using FormBuilder — much cleaner
  registerForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
    age: [null, [Validators.required, Validators.min(18), Validators.max(100)]],
    role: ['user', Validators.required]
  });
}

this.fb.group({...}) creates a FormGroup. Each key is a field name. The value is an array where the first element is the initial value and the second element is the validators (or an array of validators).

This is exactly equivalent to:

registerForm = new FormGroup({
  fullName: new FormControl('', [Validators.required, Validators.minLength(2)]),
  email: new FormControl('', [Validators.required, Validators.email]),
  // ... etc
});

FormBuilder is just a convenience wrapper. Use it — it is the standard in real Angular projects.


3.5 — All Built-in Validators

Angular's Validators class provides these built-in validators:

Validators.required         // field must have a value
Validators.requiredTrue     // value must be exactly true (for checkboxes)
Validators.email            // value must look like an email
Validators.minLength(n)     // value must be at least n characters
Validators.maxLength(n)     // value must be at most n characters
Validators.min(n)           // number value must be >= n
Validators.max(n)           // number value must be <= n
Validators.pattern(regex)   // value must match the regex pattern

You can combine multiple validators in an array:

phone: ['', [
  Validators.required,
  Validators.minLength(10),
  Validators.maxLength(10),
  Validators.pattern(/^[0-9]+$/)  // only digits
]]

3.6 — Connecting the Form to the Template

In the template, you connect the reactive form using formGroup and formControlName directives:

src/app/register/register.html:

<div class="form-container">
  <h1>Create Account</h1>

  <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">

    <div class="field">
      <label>Full Name</label>
      <input
        type="text"
        formControlName="fullName"
        placeholder="Rahul Sharma">

      @if (registerForm.get('fullName')?.invalid &&
           registerForm.get('fullName')?.touched) {
        <div class="error">
          @if (registerForm.get('fullName')?.errors?.['required']) {
            <span>Full name is required</span>
          }
          @if (registerForm.get('fullName')?.errors?.['minlength']) {
            <span>Name must be at least 2 characters</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Email</label>
      <input
        type="email"
        formControlName="email"
        placeholder="rahul@example.com">

      @if (registerForm.get('email')?.invalid &&
           registerForm.get('email')?.touched) {
        <div class="error">
          @if (registerForm.get('email')?.errors?.['required']) {
            <span>Email is required</span>
          }
          @if (registerForm.get('email')?.errors?.['email']) {
            <span>Please enter a valid email</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Password</label>
      <input
        type="password"
        formControlName="password"
        placeholder="Minimum 8 characters">

      @if (registerForm.get('password')?.invalid &&
           registerForm.get('password')?.touched) {
        <div class="error">
          @if (registerForm.get('password')?.errors?.['required']) {
            <span>Password is required</span>
          }
          @if (registerForm.get('password')?.errors?.['minlength']) {
            <span>Password must be at least 8 characters</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Confirm Password</label>
      <input
        type="password"
        formControlName="confirmPassword"
        placeholder="Re-enter your password">

      @if (registerForm.errors?.['passwordMismatch'] &&
           registerForm.get('confirmPassword')?.touched) {
        <div class="error">
          <span>Passwords do not match</span>
        </div>
      }
    </div>

    <div class="field">
      <label>Age</label>
      <input
        type="number"
        formControlName="age"
        placeholder="Must be 18 or older">

      @if (registerForm.get('age')?.invalid &&
           registerForm.get('age')?.touched) {
        <div class="error">
          @if (registerForm.get('age')?.errors?.['required']) {
            <span>Age is required</span>
          }
          @if (registerForm.get('age')?.errors?.['min']) {
            <span>You must be at least 18 years old</span>
          }
          @if (registerForm.get('age')?.errors?.['max']) {
            <span>Please enter a valid age</span>
          }
        </div>
      }
    </div>

    <div class="field">
      <label>Role</label>
      <select formControlName="role">
        <option value="user">Regular User</option>
        <option value="author">Author</option>
        <option value="admin">Admin</option>
      </select>
    </div>

    <button
      type="submit"
      [disabled]="registerForm.invalid">
      Create Account
    </button>

  </form>
</div>

The key directives:

[formGroup]="registerForm" — binds the <form> element to your TypeScript FormGroup.

formControlName="fullName" — binds an input to the fullName FormControl inside the group. This is how the input and the form control stay connected. The value of formControlName must exactly match the key you used in fb.group({...}).

registerForm.get('fullName') — gets a reference to the fullName control so you can check its validity and errors.

registerForm.get('fullName')?.invalid — the ?. is optional chaining because get() can return null if the control name does not exist.


3.7 — Reading Form Values and Handling Submission

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

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

  private fb = inject(FormBuilder);

  registerForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
    age: [null as number | null, [Validators.required, Validators.min(18)]],
    role: ['user']
  });

  isSubmitted: boolean = false;

  onSubmit(): void {
    if (this.registerForm.invalid) {
      // Mark all fields as touched so errors show
      this.registerForm.markAllAsTouched();
      return;
    }

    const formData = this.registerForm.value;
    console.log('Submitted:', formData);
    // formData.fullName, formData.email, formData.password, etc.

    this.isSubmitted = true;
    this.registerForm.reset();
  }
}

this.registerForm.value — returns a plain object with all the current field values. { fullName: 'Rahul', email: 'rahul@example.com', ... }.

this.registerForm.markAllAsTouched() — marks every single field as touched. This is very useful when the user clicks submit without filling anything in — you want to show all errors at once. Without this, errors only show for fields the user has individually clicked into and out of.

this.registerForm.reset() — resets all fields to their initial values and clears touched/dirty state.


3.8 — Getting Individual Controls More Cleanly

Writing registerForm.get('fullName') everywhere is verbose. A common pattern is to create getter properties for each control:

export class Register {

  registerForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });

  // Getter properties for easy template access
  get fullName() { return this.registerForm.get('fullName')!; }
  get email() { return this.registerForm.get('email')!; }
  get password() { return this.registerForm.get('password')!; }
}

Now in the template you write fullName.invalid instead of registerForm.get('fullName')?.invalid. Much cleaner:

@if (fullName.invalid && fullName.touched) {
  <div class="error">
    @if (fullName.errors?.['required']) {
      <span>Full name is required</span>
    }
  </div>
}

3.9 — Updating Form Values Programmatically

Sometimes you need to update form values from TypeScript — like pre-populating a form with data loaded from an API:

// setValue — sets ALL fields, must provide every field
this.registerForm.setValue({
  fullName: 'Rahul Sharma',
  email: 'rahul@example.com',
  password: '',
  confirmPassword: '',
  age: 25,
  role: 'user'
});

// patchValue — sets only the fields you provide, ignores the rest
this.registerForm.patchValue({
  fullName: 'Rahul Sharma',
  email: 'rahul@example.com'
  // other fields unchanged
});

Use patchValue when you are pre-filling only some fields. Use setValue when you are filling the entire form.


Chapter 4 — Custom Validators


4.1 — Why Custom Validators?

Angular's built-in validators cover the basics — required, email, min, max, pattern. But real applications need much more specific validation:

A phone number must be exactly 10 digits and start with 6, 7, 8, or 9. A username cannot contain spaces. A confirm password field must match the password field. A date must be in the future. A price must be a multiple of 5.

None of these are covered by built-in validators. This is where custom validators come in.


4.2 — A Custom Validator Function

A validator is just a function. It receives an AbstractControl (which can be a FormControl, FormGroup, or FormArray) and returns either null (validation passed) or an error object (validation failed).

import { AbstractControl, ValidationErrors } from '@angular/forms';

// Custom validator: no spaces allowed
export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string;

  if (!value) {
    return null; // if empty, let 'required' handle it
  }

  const hasSpaces = value.includes(' ');

  if (hasSpaces) {
    return { noSpaces: true };
    // The key 'noSpaces' is how you identify this error in the template
  }

  return null; // null means validation passed
}

The returned error object can contain any information you want. The key is the error name. The value is typically true or an object with more details:

// Simple — just flag the error
return { noSpaces: true };

// With details — useful for showing specific info in the error message
return { minLength: { required: 8, actual: value.length } };

4.3 — Using a Custom Validator

Apply it exactly like a built-in validator:

import { noSpacesValidator } from './validators/no-spaces.validator';

registerForm = this.fb.group({
  username: ['', [
    Validators.required,
    Validators.minLength(3),
    noSpacesValidator          // ← just add it to the array
  ]]
});

And check for it in the template:

@if (username.errors?.['noSpaces']) {
  <span>Username cannot contain spaces</span>
}

4.4 — A Validator with Configuration (Validator Factory)

If your validator needs configuration — like checking that a value is at least N characters but N is dynamic — you create a validator factory: a function that takes parameters and returns a validator function.

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Validator factory — takes a blacklist and returns a validator
export function notInListValidator(blacklist: string[]): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = (control.value as string)?.toLowerCase();

    if (!value) return null;

    const isBlacklisted = blacklist.includes(value);

    if (isBlacklisted) {
      return { notInList: { value: control.value } };
    }

    return null;
  };
}

Using it:

username: ['', [
  Validators.required,
  notInListValidator(['admin', 'root', 'system', 'test'])
]]

4.5 — Cross-Field Validation — The Password Match Validator

Cross-field validation is when you need to validate one field against another — the classic example being password and confirm password must match.

This validator goes on the FormGroup level, not on an individual control, because it needs to read two controls:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function passwordMatchValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get('password')?.value;
    const confirmPassword = group.get('confirmPassword')?.value;

    if (!password || !confirmPassword) {
      return null;
    }

    if (password !== confirmPassword) {
      return { passwordMismatch: true };
    }

    return null;
  };
}

Apply it to the FormGroup, not an individual control:

registerForm = this.fb.group(
  {
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required]
  },
  { validators: passwordMatchValidator() }   // ← second argument to group()
);

In the template, you check for this error on the form itself (not on a specific control) since it is a group-level error:

@if (registerForm.errors?.['passwordMismatch'] &&
     registerForm.get('confirmPassword')?.touched) {
  <div class="error">
    <span>Passwords do not match</span>
  </div>
}

Chapter 5 — FormArray — Dynamic Fields


5.1 — What is FormArray?

A FormArray is a list of form controls or form groups. You use it when the number of fields is not fixed — it changes at runtime based on user actions.

Examples: a skills list where the user can add any number of skills, an education history form where the user adds each degree, a checkout form where the user adds multiple delivery addresses.


5.2 — Building a Dynamic Skills Form

src/app/profile/profile.ts:

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormArray, Validators, AbstractControl } from '@angular/forms';

@Component({
  selector: 'app-profile',
  imports: [ReactiveFormsModule],
  templateUrl: './profile.html',
  styleUrl: './profile.css'
})
export class Profile {

  private fb = inject(FormBuilder);

  profileForm = this.fb.group({
    name: ['', Validators.required],
    bio: ['', [Validators.required, Validators.maxLength(200)]],
    skills: this.fb.array([
      this.fb.control('Angular', Validators.required),
      this.fb.control('TypeScript', Validators.required)
    ])
  });

  // Getter for easy access to the skills array
  get skills(): FormArray {
    return this.profileForm.get('skills') as FormArray;
  }

  get skillControls(): AbstractControl[] {
    return this.skills.controls;
  }

  addSkill(): void {
    // Add a new empty control to the array
    this.skills.push(this.fb.control('', Validators.required));
  }

  removeSkill(index: number): void {
    // Remove the control at this index
    this.skills.removeAt(index);
  }

  onSubmit(): void {
    if (this.profileForm.invalid) {
      this.profileForm.markAllAsTouched();
      return;
    }
    console.log('Profile:', this.profileForm.value);
  }
}

src/app/profile/profile.html:

<div class="form-container">
  <h1>Edit Profile</h1>

  <form [formGroup]="profileForm" (ngSubmit)="onSubmit()">

    <div class="field">
      <label>Name</label>
      <input type="text" formControlName="name" placeholder="Your name">
      @if (profileForm.get('name')?.invalid && profileForm.get('name')?.touched) {
        <div class="error">Name is required</div>
      }
    </div>

    <div class="field">
      <label>Bio</label>
      <textarea formControlName="bio" rows="3" placeholder="Tell us about yourself"></textarea>
      <div class="char-count">
        {{ profileForm.get('bio')?.value?.length || 0 }} / 200
      </div>
      @if (profileForm.get('bio')?.errors?.['maxlength']) {
        <div class="error">Bio cannot exceed 200 characters</div>
      }
    </div>

    <div class="field">
      <label>Skills</label>

      <div formArrayName="skills" class="skills-list">
        @for (skill of skillControls; track $index; let i = $index) {
          <div class="skill-row">
            <input
              type="text"
              [formControlName]="i"
              placeholder="e.g. Angular, TypeScript">

            @if (skills.at(i).invalid && skills.at(i).touched) {
              <span class="error">Skill name is required</span>
            }

            <button
              type="button"
              class="remove-btn"
              (click)="removeSkill(i)"
              [disabled]="skills.length <= 1">
              ✕
            </button>
          </div>
        }
      </div>

      <button type="button" class="add-btn" (click)="addSkill()">
        + Add Skill
      </button>
    </div>

    <button type="submit" [disabled]="profileForm.invalid">
      Save Profile
    </button>

  </form>
</div>

Key things happening here:

formArrayName="skills" — connects the <div> wrapper to the skills FormArray in your form.

[formControlName]="i" — inside a formArrayName, you use the numeric index as the control name. i is the loop index.

this.skills.push(...) — adds a new control at the end of the array.

this.skills.removeAt(index) — removes the control at the given index.

this.skills.length — the number of controls currently in the array.


Chapter 6 — Async Validators


6.1 — What Are Async Validators?

Some validation requires checking with the server. "Is this username already taken?" "Does this email already have an account?" You cannot do this check synchronously because it requires an HTTP request.

Async validators return a Promise or Observable instead of returning the result directly. Angular waits for the async operation to complete before deciding if the field is valid.


6.2 — Building an Async Username Validator

import { inject } from '@angular/core';
import { AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';

// Simulated list of taken usernames
const takenUsernames = ['rahul', 'priya', 'admin', 'user123'];

export function usernameAvailableValidator(): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = control.value as string;

    if (!value || value.length < 3) {
      return of(null);  // 'of' creates an Observable that immediately emits a value
    }

    // timer(500) introduces a 500ms debounce
    // This prevents checking on every single keystroke
    return timer(500).pipe(
      switchMap(() => {
        // Simulating an HTTP call — in real app this would be an actual API call
        const isTaken = takenUsernames.includes(value.toLowerCase());

        if (isTaken) {
          return of({ usernameTaken: true });
        }
        return of(null);
      }),
      catchError(() => of(null))  // if the server errors, don't block the form
    );
  };
}

Using it in the form — async validators go as the THIRD argument to FormControl or fb.control(), after sync validators:

import { usernameAvailableValidator } from './validators/username-available.validator';

registerForm = this.fb.group({
  username: [
    '',
    [Validators.required, Validators.minLength(3)],  // sync validators — 2nd arg
    [usernameAvailableValidator()]                    // async validators — 3rd arg
  ]
});

In the template, Angular provides a pending state while the async validator is running — use it to show a loading indicator:

<div class="field">
  <label>Username</label>

  <div class="input-with-status">
    <input type="text" formControlName="username" placeholder="Choose a username">

    @if (registerForm.get('username')?.pending) {
      <span class="checking">Checking...</span>
    }

    @if (registerForm.get('username')?.valid && !registerForm.get('username')?.pending) {
      <span class="available">✓ Available</span>
    }
  </div>

  @if (registerForm.get('username')?.errors?.['required'] &&
       registerForm.get('username')?.touched) {
    <div class="error">Username is required</div>
  }

  @if (registerForm.get('username')?.errors?.['minlength']) {
    <div class="error">Username must be at least 3 characters</div>
  }

  @if (registerForm.get('username')?.errors?.['usernameTaken']) {
    <div class="error">This username is already taken. Please choose another.</div>
  }
</div>

pending is true while any async validator on that control is still running. Use it to show a "Checking..." spinner.


Chapter 7 — Listening to Form Changes


7.1 — valueChanges

Reactive forms give you an Observable called valueChanges that emits a new value every time the form's value changes. You can subscribe to it to react to any change:

import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  imports: [ReactiveFormsModule],
  templateUrl: './search.html',
  styleUrl: './search.css'
})
export class Search implements OnInit, OnDestroy {

  private fb = inject(FormBuilder);
  private subscription?: Subscription;

  searchForm = this.fb.group({
    query: [''],
    category: ['all']
  });

  searchResults: string[] = [];

  ngOnInit(): void {
    // Listen to the entire form changing
    this.subscription = this.searchForm.valueChanges
      .pipe(
        debounceTime(300),          // wait 300ms after user stops typing
        distinctUntilChanged()      // only emit if value actually changed
      )
      .subscribe(value => {
        console.log('Form value changed:', value);
        this.performSearch(value.query || '', value.category || 'all');
      });
  }

  // You can also listen to a single control
  ngOnInit2(): void {
    this.searchForm.get('query')?.valueChanges
      .pipe(debounceTime(300))
      .subscribe(query => {
        console.log('Query changed:', query);
      });
  }

  performSearch(query: string, category: string): void {
    // Simulate search results
    if (query.length > 0) {
      this.searchResults = [
        `Result 1 for "${query}" in ${category}`,
        `Result 2 for "${query}" in ${category}`,
        `Result 3 for "${query}" in ${category}`
      ];
    } else {
      this.searchResults = [];
    }
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

This is the real-time search pattern. Every time the user types in the search box, valueChanges fires. debounceTime(300) waits until the user pauses typing for 300ms before running the search — preventing an API call on every single keystroke. distinctUntilChanged() prevents running the search if the value did not actually change.


7.2 — statusChanges

statusChanges emits whenever the form's validation status changes — from VALID to INVALID, INVALID to VALID, or PENDING while async validators run:

this.registerForm.statusChanges.subscribe(status => {
  console.log('Form status:', status);
  // status is 'VALID', 'INVALID', or 'PENDING'
});

Chapter 8 — A Complete Real-World Form Project


Let's build a complete User Registration and Profile system that demonstrates everything from this phase.


Setup

ng new forms-app --style=css
cd forms-app
ng g c pages/register --skip-tests
ng g c pages/login --skip-tests
ng g c pages/profile --skip-tests

Custom Validators File

src/app/validators/validators.ts:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  if (!control.value) return null;
  return (control.value as string).includes(' ')
    ? { noSpaces: true }
    : null;
}

export function passwordMatchValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get('password')?.value;
    const confirmPassword = group.get('confirmPassword')?.value;
    if (!password || !confirmPassword) return null;
    return password !== confirmPassword ? { passwordMismatch: true } : null;
  };
}

export function indianPhoneValidator(control: AbstractControl): ValidationErrors | null {
  if (!control.value) return null;
  const phone = control.value.toString();
  const isValid = /^[6-9][0-9]{9}$/.test(phone);
  return isValid ? null : { invalidPhone: true };
}

Registration Form

src/app/pages/register/register.ts:

import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import {
  noSpacesValidator,
  passwordMatchValidator,
  indianPhoneValidator
} from '../../validators/validators';

@Component({
  selector: 'app-register',
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
  styleUrl: './register.css'
})
export class Register {

  private fb = inject(FormBuilder);
  private router = inject(Router);

  isSubmitting = signal(false);
  submitSuccess = signal(false);

  form = this.fb.group(
    {
      fullName: ['', [Validators.required, Validators.minLength(2)]],
      username: ['', [Validators.required, Validators.minLength(3), noSpacesValidator]],
      email: ['', [Validators.required, Validators.email]],
      phone: ['', [Validators.required, indianPhoneValidator]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required],
      role: ['user', Validators.required],
      agreeToTerms: [false, Validators.requiredTrue]
    },
    { validators: passwordMatchValidator() }
  );

  get fullName() { return this.form.get('fullName')!; }
  get username() { return this.form.get('username')!; }
  get email() { return this.form.get('email')!; }
  get phone() { return this.form.get('phone')!; }
  get password() { return this.form.get('password')!; }
  get confirmPassword() { return this.form.get('confirmPassword')!; }
  get agreeToTerms() { return this.form.get('agreeToTerms')!; }

  onSubmit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    this.isSubmitting.set(true);

    // Simulate API call
    setTimeout(() => {
      console.log('Registration data:', this.form.value);
      this.isSubmitting.set(false);
      this.submitSuccess.set(true);
    }, 1500);
  }

  goToLogin(): void {
    this.router.navigate(['/login']);
  }
}

src/app/pages/register/register.html:

<div class="page">
  <div class="form-card">

    @if (submitSuccess()) {
      <div class="success">
        <div class="success-icon">✓</div>
        <h2>Account Created!</h2>
        <p>Welcome aboard. You can now log in.</p>
        <button (click)="goToLogin()">Go to Login</button>
      </div>
    } @else {

      <h1>Create Account</h1>
      <p class="subtitle">Join us today — it's free</p>

      <form [formGroup]="form" (ngSubmit)="onSubmit()">

        <div class="form-row">

          <div class="field">
            <label>Full Name</label>
            <input type="text" formControlName="fullName" placeholder="Rahul Sharma">
            @if (fullName.invalid && fullName.touched) {
              <div class="error">
                @if (fullName.errors?.['required']) { <span>Full name is required</span> }
                @if (fullName.errors?.['minlength']) { <span>At least 2 characters required</span> }
              </div>
            }
          </div>

          <div class="field">
            <label>Username</label>
            <input type="text" formControlName="username" placeholder="rahul_dev">
            @if (username.invalid && username.touched) {
              <div class="error">
                @if (username.errors?.['required']) { <span>Username is required</span> }
                @if (username.errors?.['minlength']) { <span>At least 3 characters required</span> }
                @if (username.errors?.['noSpaces']) { <span>Username cannot contain spaces</span> }
              </div>
            }
          </div>

        </div>

        <div class="form-row">

          <div class="field">
            <label>Email Address</label>
            <input type="email" formControlName="email" placeholder="rahul@example.com">
            @if (email.invalid && email.touched) {
              <div class="error">
                @if (email.errors?.['required']) { <span>Email is required</span> }
                @if (email.errors?.['email']) { <span>Enter a valid email address</span> }
              </div>
            }
          </div>

          <div class="field">
            <label>Phone Number</label>
            <input type="tel" formControlName="phone" placeholder="9876543210">
            @if (phone.invalid && phone.touched) {
              <div class="error">
                @if (phone.errors?.['required']) { <span>Phone is required</span> }
                @if (phone.errors?.['invalidPhone']) { <span>Enter a valid 10-digit Indian phone number</span> }
              </div>
            }
          </div>

        </div>

        <div class="form-row">

          <div class="field">
            <label>Password</label>
            <input type="password" formControlName="password" placeholder="Min. 8 characters">
            @if (password.invalid && password.touched) {
              <div class="error">
                @if (password.errors?.['required']) { <span>Password is required</span> }
                @if (password.errors?.['minlength']) { <span>At least 8 characters required</span> }
              </div>
            }
          </div>

          <div class="field">
            <label>Confirm Password</label>
            <input type="password" formControlName="confirmPassword" placeholder="Re-enter password">
            @if (form.errors?.['passwordMismatch'] && confirmPassword.touched) {
              <div class="error"><span>Passwords do not match</span></div>
            }
          </div>

        </div>

        <div class="field">
          <label>Account Type</label>
          <select formControlName="role">
            <option value="user">Regular User</option>
            <option value="author">Author</option>
            <option value="admin">Administrator</option>
          </select>
        </div>

        <div class="field checkbox-field">
          <label class="checkbox-label">
            <input type="checkbox" formControlName="agreeToTerms">
            I agree to the Terms of Service and Privacy Policy
          </label>
          @if (agreeToTerms.invalid && agreeToTerms.touched) {
            <div class="error">You must agree to the terms to continue</div>
          }
        </div>

        <button
          type="submit"
          [disabled]="form.invalid || isSubmitting()">
          {{ isSubmitting() ? 'Creating Account...' : 'Create Account' }}
        </button>

        <p class="login-link">
          Already have an account?
          <a (click)="goToLogin()" style="cursor:pointer; color:#0070f3">Sign in</a>
        </p>

      </form>

    }

  </div>
</div>

src/app/pages/register/register.css:

.page {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f9fafb;
  padding: 40px 24px;
}

.form-card {
  background: white;
  padding: 40px;
  border-radius: 16px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
  width: 100%;
  max-width: 680px;
}

h1 {
  font-size: 28px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 6px;
}

.subtitle {
  color: #888;
  font-size: 15px;
  margin-bottom: 32px;
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
}

.field {
  margin-bottom: 20px;
}

label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: #555;
  margin-bottom: 7px;
  text-transform: uppercase;
  letter-spacing: 0.4px;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
select {
  width: 100%;
  padding: 11px 14px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  font-size: 15px;
  transition: border-color 0.2s;
  box-sizing: border-box;
  font-family: inherit;
  color: #333;
}

input:focus, select:focus {
  outline: none;
  border-color: #0070f3;
  box-shadow: 0 0 0 3px rgba(0,112,243,0.08);
}

input.ng-invalid.ng-touched {
  border-color: #dc3545;
}

input.ng-valid.ng-dirty {
  border-color: #28a745;
}

.error {
  margin-top: 5px;
  font-size: 12px;
  color: #dc3545;
}

.checkbox-field { margin-top: 8px; }

.checkbox-label {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  font-size: 14px;
  color: #555;
  text-transform: none;
  letter-spacing: 0;
  cursor: pointer;
}

.checkbox-label input[type="checkbox"] {
  width: auto;
  margin-top: 2px;
  flex-shrink: 0;
}

button[type="submit"] {
  width: 100%;
  padding: 14px;
  background: #0070f3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  margin-top: 8px;
  transition: background 0.2s;
}

button[type="submit"]:hover { background: #005ac1; }
button[type="submit"]:disabled { background: #ccc; cursor: not-allowed; }

.login-link {
  text-align: center;
  margin-top: 20px;
  font-size: 14px;
  color: #888;
}

.success {
  text-align: center;
  padding: 40px 0;
}

.success-icon {
  width: 72px;
  height: 72px;
  background: #28a745;
  color: white;
  border-radius: 50%;
  font-size: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 20px;
}

.success h2 {
  font-size: 26px;
  color: #1a1a2e;
  margin-bottom: 8px;
}

.success p {
  color: #666;
  margin-bottom: 24px;
}

.success button {
  background: #0070f3;
  color: white;
  border: none;
  padding: 12px 28px;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}

App Routes

src/app/app.routes.ts:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'register',
    pathMatch: 'full'
  },
  {
    path: 'register',
    loadComponent: () => import('./pages/register/register').then(m => m.Register)
  }
];

src/app/app.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet></router-outlet>`
})
export class App { }

src/styles.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

Run ng serve -o. You have a complete registration form with:

Required field validation on every field. Email format validation. Minimum length on name, username, and password. Custom no-spaces validator on username. Custom Indian phone number validator using regex. Password match cross-field validator on the group level. Terms agreement with requiredTrue. All errors show only after the user has touched a field. markAllAsTouched() reveals all errors when submit is clicked on an incomplete form. Loading state during fake submission. Success state after submission.


Phase 7 — Complete Summary

Here is everything you learned in this phase.

Two approaches to forms — Template-Driven for simple forms, logic in the HTML. Reactive Forms for complex forms, logic in TypeScript. Both are valid but Reactive Forms are more powerful.

Template-Driven FormsFormsModule required. #form="ngForm" on the <form> element. name attribute required on every input. [(ngModel)] for two-way binding. #field="ngModel" for field-level state. Angular CSS classes — ng-valid, ng-invalid, ng-touched, ng-dirty. form.reset() to clear the form.

Reactive FormsReactiveFormsModule required. FormControl, FormGroup, FormArray are the three building blocks. FormBuilder provides cleaner syntax. [formGroup] on the form element. formControlName on inputs. form.get('field') to access a control. form.value to get all values. markAllAsTouched() to trigger all error messages.

Built-in validatorsrequired, requiredTrue, email, minLength, maxLength, min, max, pattern — all available on Validators.

Custom validators — A function that takes AbstractControl and returns null or an error object. Apply them in the validators array exactly like built-in ones. Validator factories take configuration parameters and return a validator function. Cross-field validators go on the FormGroup level.

FormArray — A list of FormControl or FormGroup instances. Add with .push(). Remove with .removeAt(index). Access controls with .controls. Connect in template with formArrayName and index-based formControlName.

Async validators — Return a Promise or Observable instead of a value directly. Go as the third argument to FormControl. pending state is true while running. Good for checking username/email availability against an API.

valueChanges and statusChanges — Observables on forms and controls. valueChanges emits on every change. statusChanges emits when validity changes. Use debounceTime and distinctUntilChanged for search-as-you-type patterns. Always unsubscribe in ngOnDestroy.


What's Next — Phase 8

In Phase 8 we cover HTTP Client and APIs — connecting your Angular app to a real backend:

Setting up HttpClient for making API calls. GET, POST, PUT, PATCH, DELETE requests with typed responses. Handling loading states and error responses. HTTP interceptors — adding auth tokens to every request, global error handling, loading indicators. Environment variables for switching between development and production API URLs. Best practices for organizing API calls in services.

Phase 6 — Routing & Navigation

Chapter 1 — What is Routing and Why Does It Exist?


1.1 — The Problem with a Single Page

Remember from Phase 2 — Angular is a Single Page Application. There is only one HTML file. Everything renders inside <app-root>.

This is great for performance. But it creates a problem.

Real applications have multiple pages. A shop has a homepage, a product listing page, a product detail page, a cart page, a checkout page, a login page. A blog has a home, individual post pages, an about page, an author page.

If everything is one page and Angular just swaps components in and out, how does the user:

  • Share a link to a specific product?
  • Bookmark a page they want to come back to?
  • Press the browser back button and go to the previous page?
  • Refresh the page and land on the same page they were on?

Without routing, none of this works. The user cannot share a URL to a specific page because there is only one URL — your app's root URL. Refreshing always takes them back to the homepage. The back button does not work.

Routing solves all of this. Angular's router watches the URL in the browser's address bar. When the URL changes, the router figures out which component should be shown and renders it. When Angular navigates, it updates the URL — so the user can share it, bookmark it, refresh, and use the back button — all exactly like a traditional website, but without any full page reloads.


1.2 — How Angular Routing Works — The Mental Model

Think of your app's URL as an address. Each address maps to a specific component.

URL                        Component Shown
────────────────────       ──────────────────
/                    →     Home
/products            →     ProductList
/products/42         →     ProductDetail  (for product with id 42)
/cart                →     Cart
/login               →     Login
/dashboard           →     Dashboard
/dashboard/settings  →     Settings (nested inside Dashboard)

The Angular router reads the current URL, finds the matching route definition, and renders the corresponding component inside a <router-outlet> tag in your template.

<router-outlet> is just a placeholder — a slot in your template that says "render the matched component here." Your Navbar and Footer stay on screen all the time, but the content between them changes as the user navigates.

┌───────────────────────────────────┐
│            NavbarComponent        │  ← always visible
├───────────────────────────────────┤
│                                   │
│         <router-outlet>           │  ← changes based on URL
│   (Home / Products / Cart / etc.) │
│                                   │
├───────────────────────────────────┤
│            FooterComponent        │  ← always visible
└───────────────────────────────────┘

Chapter 2 — Setting Up Routes


2.1 — Where Routes Are Defined

In Angular, all routes are defined in src/app/app.routes.ts. You saw this file in Phase 2 — it was empty then. Now we fill it.

import { Routes } from '@angular/router';

export const routes: Routes = [];

Routes is just a TypeScript type — it is an array of route objects. Each route object maps a URL path to a component.


2.2 — Your First Routes

Let's set up a basic app with four pages. First generate the page components:

ng g c pages/home --skip-tests
ng g c pages/products --skip-tests
ng g c pages/about --skip-tests
ng g c pages/not-found --skip-tests

It is a common convention to put page-level components in a pages/ folder to distinguish them from smaller reusable components.

Now define the routes in app.routes.ts:

import { Routes } from '@angular/router';
import { Home } from './pages/home/home';
import { Products } from './pages/products/products';
import { About } from './pages/about/about';
import { NotFound } from './pages/not-found/not-found';

export const routes: Routes = [
  {
    path: '',
    component: Home
  },
  {
    path: 'products',
    component: Products
  },
  {
    path: 'about',
    component: About
  },
  {
    path: '**',
    component: NotFound
  }
];

Each route object has two required properties:

path — the URL segment that triggers this route. An empty string '' means the root URL — when the user is at yourapp.com/. The string 'products' means yourapp.com/products.

component — the component to render when this path is matched.

The '**' path is called a wildcard route. It matches anything that did not match any previous route. Put this last always — Angular matches routes from top to bottom and stops at the first match. If you put ** first, everything would match it.


2.3 — The Router Outlet — Where Pages Render

In your app.html, you need to have <router-outlet> — this is where the matched component gets rendered:

src/app/app.html:

<app-navbar></app-navbar>

<main>
  <router-outlet></router-outlet>
</main>

<app-footer></app-footer>

When the URL is /products, Angular finds the matching route, takes the Products component, and renders it right where <router-outlet> is. The Navbar and Footer stay untouched.

In app.ts, make sure RouterOutlet is imported:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Navbar } from './navbar/navbar';
import { Footer } from './footer/footer';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, Navbar, Footer],
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App { }

2.4 — Redirects

Sometimes you want a URL to automatically redirect to another URL. For example, redirecting /home to /:

export const routes: Routes = [
  {
    path: '',
    component: Home
  },
  {
    path: 'home',
    redirectTo: '',        // redirect /home to /
    pathMatch: 'full'     // the full path must match 'home' exactly
  },
  {
    path: 'products',
    component: Products
  },
  {
    path: '**',
    redirectTo: ''         // redirect unknown URLs to home
  }
];

pathMatch: 'full' tells Angular to only redirect if the entire URL is exactly 'home', not if 'home' just appears somewhere in the URL. For empty path redirects (path: ''), always use pathMatch: 'full' to prevent unexpected matches.


Chapter 3 — Navigation in Templates


3.1 — RouterLink — Never Use href for Internal Navigation

In a regular HTML website, you navigate between pages using <a href="/about">. In Angular, you must NOT use href for internal navigation. If you use href, the browser performs a full page reload — it sends a request to the server, Angular boots up from scratch, all your state is lost. It defeats the entire purpose of a SPA.

Instead, Angular gives you the RouterLink directive. It looks like a regular link but it tells Angular's router to navigate without reloading the page:

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-navbar',
  imports: [RouterLink],    // ← must import RouterLink
  templateUrl: './navbar.html',
  styleUrl: './navbar.css'
})
export class Navbar { }
<nav>
  <div class="logo">MyApp</div>
  <ul>
    <li><a routerLink="/">Home</a></li>
    <li><a routerLink="/products">Products</a></li>
    <li><a routerLink="/about">About</a></li>
  </ul>
</nav>

routerLink="/" — navigates to the home page. routerLink="/products" — navigates to the products page.

When the user clicks these links, Angular intercepts the click, updates the URL in the address bar, and renders the matching component. No page reload. Instant.


3.2 — RouterLinkActive — Highlighting the Active Link

You almost always want to highlight the navigation link for the currently active page. RouterLinkActive adds a CSS class to a link when its route is currently active:

import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';

@Component({
  selector: 'app-navbar',
  imports: [RouterLink, RouterLinkActive],
  templateUrl: './navbar.html',
  styleUrl: './navbar.css'
})
export class Navbar { }
<nav>
  <ul>
    <li>
      <a routerLink="/"
         routerLinkActive="active"
         [routerLinkActiveOptions]="{ exact: true }">
        Home
      </a>
    </li>
    <li>
      <a routerLink="/products"
         routerLinkActive="active">
        Products
      </a>
    </li>
    <li>
      <a routerLink="/about"
         routerLinkActive="active">
        About
      </a>
    </li>
  </ul>
</nav>

routerLinkActive="active" — Angular adds the class active to this element whenever the current URL matches the routerLink of this element. You define what active looks like in your CSS.

[routerLinkActiveOptions]="{ exact: true }" — this is important for the home route /. Without exact: true, the / link would be highlighted as active on every page because every URL starts with /. With exact: true, it only activates when the URL is exactly /.

/* navbar.css */
a {
  color: #ccd6f6;
  text-decoration: none;
  padding: 6px 12px;
  border-radius: 6px;
  transition: all 0.2s;
}

a.active {
  color: #64ffda;
  background: rgba(100, 255, 218, 0.1);
}

3.3 — Dynamic RouterLink with Property Binding

When you need to build the link dynamically — for example, linking to a specific product — use property binding:

<!-- Link to a specific product page -->
<a [routerLink]="['/products', product.id]">{{ product.name }}</a>
<!-- Generates: /products/42 -->

<!-- Link with query params -->
<a [routerLink]="['/products']" [queryParams]="{ category: 'electronics', sort: 'price' }">
  Electronics
</a>
<!-- Generates: /products?category=electronics&sort=price -->

The [routerLink] accepts an array where each element is a URL segment. ['/products', product.id] generates /products/42 when product.id is 42.


Chapter 4 — Programmatic Navigation


4.1 — Navigating from TypeScript Code

Sometimes you need to navigate in your TypeScript code — not from a link click. For example, after a form is submitted successfully, you want to redirect the user to a different page. After a successful login, redirect to the dashboard.

For this you use the Router service:

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-login',
  imports: [FormsModule],
  templateUrl: './login.html',
  styleUrl: './login.css'
})
export class Login {

  private router = inject(Router);

  email: string = '';
  password: string = '';

  onLogin(): void {
    // Pretend we validated the login here
    const loginSuccessful = true;

    if (loginSuccessful) {
      // Navigate to dashboard after successful login
      this.router.navigate(['/dashboard']);
    }
  }

  goToRegister(): void {
    this.router.navigate(['/register']);
  }

  goToProductDetail(productId: number): void {
    this.router.navigate(['/products', productId]);
  }
}

this.router.navigate(['/dashboard']) — navigates to /dashboard. The navigate method takes an array of URL segments, just like [routerLink].


4.2 — Navigate with Query Params

// Navigate to /products?category=electronics&sort=price
this.router.navigate(['/products'], {
  queryParams: { category: 'electronics', sort: 'price' }
});

4.3 — navigateByUrl

navigateByUrl is an alternative that takes a complete URL string instead of an array:

// Navigate using a full URL string
this.router.navigateByUrl('/products?category=electronics');

// Navigate to home
this.router.navigateByUrl('/');

The difference between navigate and navigateByUrl is that navigate builds the URL from segments (array-based) and navigateByUrl takes the URL directly as a string. Both work fine. Use navigate when you are building URLs from dynamic values. Use navigateByUrl when you already have the complete URL string.


Chapter 5 — Route Parameters


5.1 — What Are Route Parameters?

A route parameter is a dynamic part of the URL that changes based on what the user is viewing. Instead of creating a separate route for every product like /products/keyboard, /products/mouse, /products/monitor — you create one route with a parameter: /products/:id.

The :id is a placeholder. When the URL is /products/42, Angular puts 42 into the id parameter. When the URL is /products/99, Angular puts 99 into the id parameter. Same route, same component, different data.


5.2 — Defining a Route with Parameters

// app.routes.ts
import { Routes } from '@angular/router';
import { Home } from './pages/home/home';
import { Products } from './pages/products/products';
import { ProductDetail } from './pages/product-detail/product-detail';

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'products', component: Products },
  { path: 'products/:id', component: ProductDetail },  // ← :id is a parameter
  { path: '**', redirectTo: '' }
];

Now /products/1 renders ProductDetail. /products/99 also renders ProductDetail. The component reads the id from the URL to know which product to display.


5.3 — Reading Route Parameters in the Component

To read the current URL's parameters, inject ActivatedRoute:

src/app/pages/product-detail/product-detail.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { RouterLink } from '@angular/router';

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
  category: string;
  rating: number;
}

@Component({
  selector: 'app-product-detail',
  imports: [RouterLink],
  templateUrl: './product-detail.html',
  styleUrl: './product-detail.css'
})
export class ProductDetail implements OnInit {

  private route = inject(ActivatedRoute);

  product = signal<Product | null>(null);
  isLoading = signal<boolean>(true);
  error = signal<string>('');

  // Simulated product database
  private allProducts: Product[] = [
    { id: 1, name: 'Mechanical Keyboard', price: 8500, description: 'Premium tactile feedback keyboard with RGB lighting.', category: 'Electronics', rating: 4.8 },
    { id: 2, name: 'Wireless Mouse', price: 2200, description: 'Ergonomic wireless mouse with 3 months battery life.', category: 'Electronics', rating: 4.5 },
    { id: 3, name: 'Monitor Stand', price: 3400, description: 'Adjustable aluminum monitor stand with cable management.', category: 'Accessories', rating: 4.7 },
    { id: 4, name: 'USB-C Hub', price: 1800, description: '7-in-1 USB-C hub with 4K HDMI, USB 3.0, and PD charging.', category: 'Electronics', rating: 4.3 }
  ];

  ngOnInit(): void {
    // Read the :id parameter from the current URL
    const idParam = this.route.snapshot.params['id'];
    const productId = Number(idParam);

    // Find the product with this ID
    const found = this.allProducts.find(p => p.id === productId);

    if (found) {
      this.product.set(found);
    } else {
      this.error.set(`Product with ID ${productId} was not found.`);
    }

    this.isLoading.set(false);
  }
}

ActivatedRoute is a service that gives you information about the currently active route. this.route.snapshot.params['id'] reads the id parameter from the URL at the moment the component loads.

snapshot means "the current state of the route right now." It is a one-time read. This is perfect when the component is loaded fresh every time the user navigates to it.

this.route.snapshot.params['id'] always returns a string — even if the URL has a number in it. URL parameters are always strings. That is why we convert it with Number(idParam).

src/app/pages/product-detail/product-detail.html:

<div class="product-detail">

  @if (isLoading()) {
    <div class="loading">Loading product...</div>
  }

  @if (error()) {
    <div class="error">
      <p>{{ error() }}</p>
      <a routerLink="/products">← Back to Products</a>
    </div>
  }

  @if (product()) {
    <div class="detail-card">
      <a routerLink="/products" class="back-link">← Back to Products</a>

      <div class="product-header">
        <span class="category">{{ product()!.category }}</span>
        <h1>{{ product()!.name }}</h1>
        <div class="rating">★ {{ product()!.rating }} / 5</div>
      </div>

      <p class="description">{{ product()!.description }}</p>

      <div class="price-section">
        <span class="price">₹{{ product()!.price.toLocaleString() }}</span>
        <button class="add-to-cart">Add to Cart</button>
      </div>
    </div>
  }

</div>

5.4 — Subscribing to Route Parameters for Same-Component Navigation

snapshot works perfectly when the user navigates to the component fresh. But there is a specific situation where it fails — when the user is already on a product detail page and navigates to a different product.

For example, if you are on /products/1 and click "Next Product" which takes you to /products/2 — Angular might reuse the same component instance instead of destroying and recreating it. In that case, ngOnInit does not run again, so snapshot is still reading the old ID.

To handle this, subscribe to route.params as an Observable:

import { Component, inject, OnInit, OnDestroy, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-product-detail',
  imports: [],
  templateUrl: './product-detail.html',
  styleUrl: './product-detail.css'
})
export class ProductDetail implements OnInit, OnDestroy {

  private route = inject(ActivatedRoute);
  private paramSubscription?: Subscription;

  productId = signal<number>(0);

  ngOnInit(): void {
    // Subscribe to params — this fires every time the param changes
    this.paramSubscription = this.route.params.subscribe(params => {
      const id = Number(params['id']);
      this.productId.set(id);
      this.loadProduct(id);
    });
  }

  loadProduct(id: number): void {
    console.log('Loading product with ID:', id);
    // Load the product data here
  }

  ngOnDestroy(): void {
    // Always unsubscribe to prevent memory leaks
    this.paramSubscription?.unsubscribe();
  }
}

this.route.params is an Observable that emits a new value every time the route parameters change. By subscribing to it, your component reacts every time the ID in the URL changes — even if the component instance is reused.

We cover Observables deeply in Phase 9. For now just know: snapshot for simple cases, subscribe to route.params when the same component can be navigated to with different parameters while it is already active.


5.5 — Query Parameters

Query parameters are the ?key=value parts of a URL. Unlike route parameters which are part of the URL path, query parameters are optional extras that provide additional context.

/products?category=electronics&sort=price&page=2

Reading query parameters:

import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-products',
  imports: [],
  templateUrl: './products.html',
  styleUrl: './products.css'
})
export class Products implements OnInit {

  private route = inject(ActivatedRoute);

  category = signal<string>('all');
  sortBy = signal<string>('name');
  currentPage = signal<number>(1);

  ngOnInit(): void {
    // Read query params from the URL
    const params = this.route.snapshot.queryParams;

    this.category.set(params['category'] || 'all');
    this.sortBy.set(params['sort'] || 'name');
    this.currentPage.set(Number(params['page']) || 1);

    console.log(`Showing category: ${this.category()}, sorted by: ${this.sortBy()}`);
  }
}

Setting query parameters when navigating:

// In TypeScript
this.router.navigate(['/products'], {
  queryParams: {
    category: 'electronics',
    sort: 'price',
    page: 1
  }
});
<!-- In template -->
<a [routerLink]="['/products']"
   [queryParams]="{ category: 'electronics', sort: 'price' }">
  Electronics
</a>

Chapter 6 — Nested Routes


6.1 — What Are Nested Routes?

Nested routes are routes that render components inside other components — not just in the root <router-outlet>, but inside a <router-outlet> that lives inside another routed component.

This is useful for dashboard-style layouts where the outer shell (sidebar, header) stays the same but the inner content changes:

/dashboard              → Dashboard shell
/dashboard/overview     → Dashboard overview (inside dashboard)
/dashboard/stats        → Dashboard stats (inside dashboard)
/dashboard/settings     → Dashboard settings (inside dashboard)

The Dashboard component renders once. Inside it, there is a second <router-outlet>. As the user navigates between /dashboard/overview, /dashboard/stats, and /dashboard/settings, only the inner content changes.


6.2 — Setting Up Nested Routes

// app.routes.ts
import { Routes } from '@angular/router';
import { Home } from './pages/home/home';
import { Dashboard } from './pages/dashboard/dashboard';
import { Overview } from './pages/dashboard/overview/overview';
import { Stats } from './pages/dashboard/stats/stats';
import { Settings } from './pages/dashboard/settings/settings';

export const routes: Routes = [
  { path: '', component: Home },
  {
    path: 'dashboard',
    component: Dashboard,
    children: [                              // ← children array for nested routes
      { path: '', redirectTo: 'overview', pathMatch: 'full' },
      { path: 'overview', component: Overview },
      { path: 'stats', component: Stats },
      { path: 'settings', component: Settings }
    ]
  }
];

The children array defines routes that render inside the Dashboard component. When the URL is /dashboard/overview, Angular renders Dashboard in the root outlet AND renders Overview inside Dashboard's own outlet.


6.3 — The Parent Component with Its Own Router Outlet

The Dashboard component needs its own <router-outlet> where child components will render:

src/app/pages/dashboard/dashboard.ts:

import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Auth } from '../../auth/auth';

@Component({
  selector: 'app-dashboard',
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  templateUrl: './dashboard.html',
  styleUrl: './dashboard.css'
})
export class Dashboard {

  authService = inject(Auth);
  userName = this.authService.userName;
}

src/app/pages/dashboard/dashboard.html:

<div class="dashboard-layout">

  <aside class="sidebar">
    <div class="sidebar-header">
      <h2>Dashboard</h2>
      <p>{{ userName() }}</p>
    </div>

    <nav class="sidebar-nav">
      <a routerLink="overview" routerLinkActive="active">
        📊 Overview
      </a>
      <a routerLink="stats" routerLinkActive="active">
        📈 Statistics
      </a>
      <a routerLink="settings" routerLinkActive="active">
        ⚙️ Settings
      </a>
    </nav>
  </aside>

  <main class="dashboard-content">
    <router-outlet></router-outlet>
    <!-- Child routes render here -->
  </main>

</div>

Notice the routerLink values inside the dashboard are relative — routerLink="overview" not routerLink="/dashboard/overview". When you are inside a routed component and use a routerLink without a leading /, Angular treats it as relative to the current route. So overview becomes /dashboard/overview automatically.

src/app/pages/dashboard/dashboard.css:

.dashboard-layout {
  display: flex;
  min-height: calc(100vh - 60px);
}

.sidebar {
  width: 240px;
  background: #1a1a2e;
  padding: 24px 0;
  flex-shrink: 0;
}

.sidebar-header {
  padding: 0 24px 24px;
  border-bottom: 1px solid #233554;
  margin-bottom: 16px;
}

.sidebar-header h2 {
  color: #64ffda;
  font-size: 18px;
  margin-bottom: 4px;
}

.sidebar-header p {
  color: #8892b0;
  font-size: 13px;
}

.sidebar-nav {
  display: flex;
  flex-direction: column;
  padding: 0 12px;
  gap: 4px;
}

.sidebar-nav a {
  display: block;
  padding: 10px 12px;
  color: #8892b0;
  text-decoration: none;
  border-radius: 8px;
  font-size: 14px;
  transition: all 0.2s;
}

.sidebar-nav a:hover {
  color: #ccd6f6;
  background: rgba(255,255,255,0.05);
}

.sidebar-nav a.active {
  color: #64ffda;
  background: rgba(100, 255, 218, 0.1);
}

.dashboard-content {
  flex: 1;
  padding: 32px;
  background: #f0f2f5;
  overflow-y: auto;
}

Now build the three child page components:

src/app/pages/dashboard/overview/overview.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-overview',
  imports: [],
  template: `
    <div class="page">
      <h1>Overview</h1>

      <div class="stats-grid">
        <div class="stat-card">
          <span class="stat-value">1,284</span>
          <span class="stat-label">Total Users</span>
        </div>
        <div class="stat-card">
          <span class="stat-value">₹2,40,000</span>
          <span class="stat-label">Revenue</span>
        </div>
        <div class="stat-card">
          <span class="stat-value">342</span>
          <span class="stat-label">Orders</span>
        </div>
        <div class="stat-card">
          <span class="stat-value">98%</span>
          <span class="stat-label">Uptime</span>
        </div>
      </div>
    </div>
  `,
  styles: [`
    .page h1 { font-size: 28px; color: #1a1a2e; margin-bottom: 28px; }
    .stats-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
      gap: 20px;
    }
    .stat-card {
      background: white;
      padding: 24px;
      border-radius: 12px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.06);
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    .stat-value { font-size: 32px; font-weight: 700; color: #0070f3; }
    .stat-label { font-size: 13px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
  `]
})
export class Overview { }

src/app/pages/dashboard/stats/stats.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-stats',
  imports: [],
  template: `
    <div class="page">
      <h1>Statistics</h1>
      <p style="color:#666; margin-top:12px">Detailed statistics and charts would appear here.</p>
    </div>
  `,
  styles: [`.page h1 { font-size: 28px; color: #1a1a2e; }`]
})
export class Stats { }

src/app/pages/dashboard/settings/settings.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-settings',
  imports: [],
  template: `
    <div class="page">
      <h1>Settings</h1>
      <p style="color:#666; margin-top:12px">User and application settings would appear here.</p>
    </div>
  `,
  styles: [`.page h1 { font-size: 28px; color: #1a1a2e; }`]
})
export class Settings { }

Chapter 7 — Route Guards


7.1 — What is a Route Guard?

A route guard is a function that runs before a route is activated. It decides whether the navigation should proceed or be blocked.

The most common use case is protecting routes from unauthorized access. You do not want unauthenticated users reaching the /dashboard. You do not want regular users reaching /admin. Guards check the condition and either let the navigation proceed or redirect the user somewhere else.

Angular has several types of guards:

canActivate — can the user enter this route? canActivateChild — can the user enter any child routes? canDeactivate — can the user leave this route? (useful for unsaved changes warnings) canMatch — should this route even be considered for matching? resolve — fetch data before the component loads

We will cover the two most common: canActivate and canDeactivate.


7.2 — Creating a canActivate Guard

Generate a guard:

ng generate guard guards/auth --skip-tests

The CLI asks what interfaces you want to implement. Select CanActivate.

This creates src/app/guards/auth.ts:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '../auth/auth';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(Auth);
  const router = inject(Router);

  if (authService.isLoggedIn()) {
    return true;   // allow navigation to proceed
  }

  // User is not logged in — redirect to login page
  router.navigate(['/login'], {
    queryParams: { returnUrl: state.url }   // remember where they were trying to go
  });

  return false;    // block navigation
};

In modern Angular, guards are just functions — CanActivateFn. The function receives the route being activated (route) and the current router state (state). It returns either true (allow navigation) or false (block navigation), or it can redirect by calling router.navigate() and returning false.

The returnUrl query param is a nice UX touch — after the user logs in, you can redirect them back to the page they were trying to reach.

state.url is the URL the user was trying to navigate to. Storing it as a query param means your login component can read it and redirect after successful login:

// In the login component, after successful login:
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
this.router.navigateByUrl(returnUrl);

7.3 — Applying a Guard to Routes

Add the guard to any route using canActivate:

// app.routes.ts
import { authGuard } from './guards/auth';

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'login', component: Login },
  { path: 'products', component: Products },
  {
    path: 'dashboard',
    component: Dashboard,
    canActivate: [authGuard],       // ← protected by auth guard
    children: [
      { path: '', redirectTo: 'overview', pathMatch: 'full' },
      { path: 'overview', component: Overview },
      { path: 'stats', component: Stats },
      { path: 'settings', component: Settings }
    ]
  },
  { path: '**', redirectTo: '' }
];

Now if an unauthenticated user tries to go to /dashboard, the guard runs, finds they are not logged in, redirects them to /login?returnUrl=/dashboard, and blocks the navigation.


7.4 — canActivateChild — Protecting All Child Routes

Instead of adding canActivate to every child route individually, you can use canActivateChild on the parent to protect all children at once:

import { adminGuard } from './guards/admin';

export const routes: Routes = [
  {
    path: 'admin',
    component: AdminLayout,
    canActivateChild: [adminGuard],   // ← all children are protected
    children: [
      { path: 'users', component: UserManagement },
      { path: 'products', component: ProductManagement },
      { path: 'orders', component: OrderManagement }
    ]
  }
];

And the admin guard:

src/app/guards/admin.ts:

import { inject } from '@angular/core';
import { CanActivateChildFn, Router } from '@angular/router';
import { Auth } from '../auth/auth';

export const adminGuard: CanActivateChildFn = (route, state) => {
  const authService = inject(Auth);
  const router = inject(Router);

  if (authService.isLoggedIn() && authService.isAdmin()) {
    return true;
  }

  if (!authService.isLoggedIn()) {
    router.navigate(['/login']);
    return false;
  }

  // Logged in but not admin
  router.navigate(['/dashboard']);
  return false;
};

7.5 — canDeactivate — Preventing Accidental Navigation Away

This guard protects users from accidentally leaving a page with unsaved changes. You know that dialog that says "You have unsaved changes. Are you sure you want to leave?" — that is a canDeactivate guard.

First create the guard:

src/app/guards/unsaved-changes.ts:

import { CanDeactivateFn } from '@angular/router';

// Define an interface that components must implement to use this guard
export interface HasUnsavedChanges {
  hasUnsavedChanges(): boolean;
}

export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) => {
  if (component.hasUnsavedChanges()) {
    return confirm('You have unsaved changes. Are you sure you want to leave?');
  }
  return true;
};

Then implement the interface in your component:

src/app/pages/edit-profile/edit-profile.ts:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HasUnsavedChanges } from '../../guards/unsaved-changes';

@Component({
  selector: 'app-edit-profile',
  imports: [FormsModule],
  templateUrl: './edit-profile.html',
  styleUrl: './edit-profile.css'
})
export class EditProfile implements HasUnsavedChanges {

  originalName: string = 'Rahul Sharma';
  currentName: string = 'Rahul Sharma';
  isSaved: boolean = false;

  hasUnsavedChanges(): boolean {
    return this.currentName !== this.originalName && !this.isSaved;
  }

  saveChanges(): void {
    this.originalName = this.currentName;
    this.isSaved = true;
    console.log('Changes saved!');
  }
}

Apply it to the route:

import { unsavedChangesGuard } from './guards/unsaved-changes';

{ path: 'edit-profile', component: EditProfile, canDeactivate: [unsavedChangesGuard] }

Now if a user has typed something in the form and tries to navigate away, they get a confirmation dialog. If they click OK, navigation proceeds. If they click Cancel, they stay on the page.


Chapter 8 — Lazy Loading


8.1 — What is Lazy Loading and Why Does It Matter?

When you build an Angular app and run ng build, all your components get compiled into JavaScript files. By default, ALL of this JavaScript loads upfront when the user first opens your app — even parts they might never visit.

If your app has 50 components and the user only visits 10 of them in a session, they downloaded JavaScript for 40 components they never needed. This makes the initial load slower.

Lazy loading solves this. With lazy loading, each route's code is only downloaded when the user actually navigates to that route. The initial bundle is tiny. When the user navigates to /dashboard, Angular downloads the dashboard's code at that moment. Fast initial load. Code loads on demand.


8.2 — Setting Up Lazy Loading

Instead of importing the component at the top of app.routes.ts and passing it directly to component:, you use loadComponent with a dynamic import:

// app.routes.ts — WITH lazy loading
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./pages/home/home').then(m => m.Home)
  },
  {
    path: 'products',
    loadComponent: () => import('./pages/products/products').then(m => m.Products)
  },
  {
    path: 'products/:id',
    loadComponent: () => import('./pages/product-detail/product-detail').then(m => m.ProductDetail)
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./pages/dashboard/dashboard').then(m => m.Dashboard),
    children: [
      { path: '', redirectTo: 'overview', pathMatch: 'full' },
      {
        path: 'overview',
        loadComponent: () => import('./pages/dashboard/overview/overview').then(m => m.Overview)
      },
      {
        path: 'stats',
        loadComponent: () => import('./pages/dashboard/stats/stats').then(m => m.Stats)
      },
      {
        path: 'settings',
        loadComponent: () => import('./pages/dashboard/settings/settings').then(m => m.Settings)
      }
    ]
  },
  {
    path: '**',
    loadComponent: () => import('./pages/not-found/not-found').then(m => m.NotFound)
  }
];

loadComponent takes a function that returns a dynamic import. import('./pages/home/home') is JavaScript's dynamic import — it only downloads that file when this function is called. The .then(m => m.Home) extracts the Home class from the module.

The first time a user navigates to /products, Angular downloads that component's code. The second time they go to /products, the code is already cached — no download needed.


8.3 — Loading State During Lazy Load

When a route is loading for the first time, there might be a brief delay while the JavaScript downloads. Angular lets you configure what to show during this loading:

In app.config.ts, add withRouterConfig to configure the router:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withRouterConfig } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(
      routes,
      withRouterConfig({ onSameUrlNavigation: 'reload' })
    )
  ]
};

For showing a loading indicator during route transitions, you listen to router events in your App component:

src/app/app.ts:

import { Component, inject, signal } from '@angular/core';
import { RouterOutlet, Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError, Event } from '@angular/router';
import { Navbar } from './navbar/navbar';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, Navbar],
  templateUrl: './app.html',
  styleUrl: './app.css'
})
export class App {

  private router = inject(Router);

  isNavigating = signal(false);

  constructor() {
    this.router.events.subscribe((event: Event) => {
      if (event instanceof NavigationStart) {
        this.isNavigating.set(true);
      }

      if (
        event instanceof NavigationEnd ||
        event instanceof NavigationCancel ||
        event instanceof NavigationError
      ) {
        this.isNavigating.set(false);
      }
    });
  }
}

src/app/app.html:

<app-navbar></app-navbar>

@if (isNavigating()) {
  <div class="page-loader">
    <div class="loader-bar"></div>
  </div>
}

<main>
  <router-outlet></router-outlet>
</main>

src/app/app.css:

.page-loader {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  z-index: 9999;
}

.loader-bar {
  height: 100%;
  background: #64ffda;
  animation: loading 1s ease-in-out infinite;
}

@keyframes loading {
  0% { width: 0%; }
  50% { width: 70%; }
  100% { width: 100%; }
}

Chapter 9 — Route Data and Extras


9.1 — Static Data on Routes

Sometimes you want to attach static data to a route — like a page title or breadcrumb label. You can do this with the data property:

export const routes: Routes = [
  {
    path: '',
    component: Home,
    data: { title: 'Home', breadcrumb: 'Home' }
  },
  {
    path: 'products',
    component: Products,
    data: { title: 'All Products', breadcrumb: 'Products' }
  },
  {
    path: 'dashboard',
    component: Dashboard,
    data: { title: 'Dashboard', requiresAuth: true }
  }
];

Reading it in the component:

import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-products',
  imports: [],
  templateUrl: './products.html',
  styleUrl: './products.css'
})
export class Products implements OnInit {

  private route = inject(ActivatedRoute);
  pageTitle = signal('');

  ngOnInit(): void {
    this.pageTitle.set(this.route.snapshot.data['title']);
    document.title = this.route.snapshot.data['title'] + ' | MyApp';
  }
}

Chapter 10 — Putting It All Together — A Complete Routed App


Let's build a Blog App that demonstrates everything from this phase. It will have a home page, a blog post listing page, individual post pages, a dashboard, and authentication with a guard.


Project Setup

ng new blog-app --style=css
cd blog-app

ng g s services/auth --skip-tests
ng g g guards/auth --skip-tests
ng g c components/navbar --skip-tests
ng g c pages/home --skip-tests
ng g c pages/blog --skip-tests
ng g c pages/post-detail --skip-tests
ng g c pages/login --skip-tests
ng g c pages/not-found --skip-tests
ng g c pages/dashboard/dashboard --skip-tests
ng g c pages/dashboard/my-posts --skip-tests
ng g c pages/dashboard/write-post --skip-tests

Auth Service

src/app/services/auth.ts:

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class Auth {

  private loggedIn = signal<boolean>(false);
  private username = signal<string>('');

  readonly isLoggedIn = this.loggedIn.asReadonly();
  readonly userName = this.username.asReadonly();
  readonly userInitial = computed(() => this.username() ? this.username()[0].toUpperCase() : '');

  login(name: string, password: string): boolean {
    if (password === 'password') {
      this.loggedIn.set(true);
      this.username.set(name);
      return true;
    }
    return false;
  }

  logout(): void {
    this.loggedIn.set(false);
    this.username.set('');
  }
}

Auth Guard

src/app/guards/auth.ts:

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '../services/auth';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(Auth);
  const router = inject(Router);

  if (auth.isLoggedIn()) {
    return true;
  }

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

Blog Data Service

src/app/services/blog.ts:

import { Injectable, signal } from '@angular/core';

export interface Post {
  id: number;
  title: string;
  excerpt: string;
  content: string;
  author: string;
  date: string;
  category: string;
  readTime: number;
}

@Injectable({ providedIn: 'root' })
export class Blog {

  private posts = signal<Post[]>([
    {
      id: 1,
      title: 'Getting Started with Angular 21',
      excerpt: 'A complete beginner guide to building your first Angular app from scratch.',
      content: 'Angular is a powerful framework for building web applications. In this guide we will cover everything from setup to deployment. We start by installing Node.js and the Angular CLI. Then we create our first project with ng new. The folder structure is clean and well-organized. Components are the building blocks of every Angular app...',
      author: 'Rahul Sharma',
      date: '2025-03-01',
      category: 'Angular',
      readTime: 8
    },
    {
      id: 2,
      title: 'Understanding TypeScript Generics',
      excerpt: 'Deep dive into TypeScript generics and how they make your code more reusable and type-safe.',
      content: 'TypeScript generics allow you to write functions, classes, and interfaces that work with a variety of types while maintaining type safety. The most basic generic is a function that returns the first item from an array...',
      author: 'Priya Patel',
      date: '2025-03-05',
      category: 'TypeScript',
      readTime: 6
    },
    {
      id: 3,
      title: 'RxJS Operators You Need to Know',
      excerpt: 'The most important RxJS operators explained clearly with practical examples.',
      content: 'RxJS is the reactive programming library that powers Angular. While it has dozens of operators, you only need to know a handful to be productive. The map operator transforms each emitted value. The filter operator only lets through values that match a condition...',
      author: 'Amit Kumar',
      date: '2025-03-10',
      category: 'RxJS',
      readTime: 10
    },
    {
      id: 4,
      title: 'Angular Services and Dependency Injection',
      excerpt: 'Learn how Angular DI works under the hood and how to use services effectively.',
      content: 'Dependency Injection is one of Angular\'s core features. It allows you to write loosely coupled code that is easy to test and maintain. A service is a class decorated with @Injectable that can be injected into any component...',
      author: 'Sneha Gupta',
      date: '2025-03-15',
      category: 'Angular',
      readTime: 7
    }
  ]);

  readonly allPosts = this.posts.asReadonly();

  getPostById(id: number): Post | undefined {
    return this.posts().find(p => p.id === id);
  }

  getPostsByCategory(category: string): Post[] {
    return this.posts().filter(p => p.category === category);
  }
}

Routes Configuration

src/app/app.routes.ts:

import { Routes } from '@angular/router';
import { authGuard } from './guards/auth';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./pages/home/home').then(m => m.Home),
    data: { title: 'Home' }
  },
  {
    path: 'blog',
    loadComponent: () => import('./pages/blog/blog').then(m => m.Blog),
    data: { title: 'Blog' }
  },
  {
    path: 'blog/:id',
    loadComponent: () => import('./pages/post-detail/post-detail').then(m => m.PostDetail)
  },
  {
    path: 'login',
    loadComponent: () => import('./pages/login/login').then(m => m.Login)
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () => import('./pages/dashboard/dashboard').then(m => m.Dashboard),
    children: [
      { path: '', redirectTo: 'my-posts', pathMatch: 'full' },
      {
        path: 'my-posts',
        loadComponent: () => import('./pages/dashboard/my-posts/my-posts').then(m => m.MyPosts)
      },
      {
        path: 'write',
        loadComponent: () => import('./pages/dashboard/write-post/write-post').then(m => m.WritePost)
      }
    ]
  },
  {
    path: '**',
    loadComponent: () => import('./pages/not-found/not-found').then(m => m.NotFound)
  }
];

Navbar Component

src/app/components/navbar/navbar.ts:

import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Auth } from '../../services/auth';

@Component({
  selector: 'app-navbar',
  imports: [RouterLink, RouterLinkActive],
  templateUrl: './navbar.html',
  styleUrl: './navbar.css'
})
export class Navbar {

  authService = inject(Auth);

  isLoggedIn = this.authService.isLoggedIn;
  userName = this.authService.userName;

  logout(): void {
    this.authService.logout();
  }
}

src/app/components/navbar/navbar.html:

<nav>
  <a routerLink="/" class="logo">DevBlog</a>

  <div class="nav-links">
    <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
    <a routerLink="/blog" routerLinkActive="active">Blog</a>

    @if (isLoggedIn()) {
      <a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
      <span class="user-chip">{{ userName() }}</span>
      <button class="logout-btn" (click)="logout()">Logout</button>
    } @else {
      <a routerLink="/login" routerLinkActive="active" class="login-link">Login</a>
    }
  </div>
</nav>

src/app/components/navbar/navbar.css:

nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 48px;
  height: 60px;
  background: white;
  border-bottom: 1px solid #e8e8e8;
  position: sticky;
  top: 0;
  z-index: 100;
}

.logo {
  font-size: 20px;
  font-weight: 800;
  color: #0070f3;
  text-decoration: none;
  letter-spacing: -0.5px;
}

.nav-links {
  display: flex;
  align-items: center;
  gap: 24px;
}

.nav-links a {
  color: #555;
  text-decoration: none;
  font-size: 15px;
  transition: color 0.2s;
}

.nav-links a:hover { color: #0070f3; }
.nav-links a.active { color: #0070f3; font-weight: 600; }

.user-chip {
  background: #f0f7ff;
  color: #0070f3;
  padding: 4px 12px;
  border-radius: 20px;
  font-size: 13px;
  font-weight: 600;
}

.logout-btn {
  background: none;
  border: 1px solid #ddd;
  color: #666;
  padding: 6px 14px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 13px;
  transition: all 0.2s;
}

.logout-btn:hover { border-color: #e94560; color: #e94560; }

.login-link {
  background: #0070f3;
  color: white !important;
  padding: 7px 18px;
  border-radius: 8px;
  font-weight: 600;
}

.login-link:hover { background: #005ac1; }

Home Page

src/app/pages/home/home.ts:

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-home',
  imports: [RouterLink],
  templateUrl: './home.html',
  styleUrl: './home.css'
})
export class Home { }

src/app/pages/home/home.html:

<div class="hero">
  <h1>Welcome to DevBlog</h1>
  <p>Tutorials on Angular, TypeScript, and modern web development.</p>
  <div class="hero-actions">
    <a routerLink="/blog" class="btn-primary">Read Articles</a>
    <a routerLink="/login" class="btn-secondary">Start Writing</a>
  </div>
</div>

src/app/pages/home/home.css:

.hero {
  text-align: center;
  padding: 80px 24px;
  max-width: 700px;
  margin: 0 auto;
}

h1 {
  font-size: 52px;
  font-weight: 800;
  color: #1a1a2e;
  line-height: 1.1;
  margin-bottom: 16px;
  letter-spacing: -1px;
}

p {
  font-size: 18px;
  color: #666;
  margin-bottom: 36px;
}

.hero-actions { display: flex; gap: 16px; justify-content: center; }

.btn-primary {
  background: #0070f3;
  color: white;
  padding: 13px 28px;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 600;
  font-size: 15px;
  transition: background 0.2s;
}

.btn-primary:hover { background: #005ac1; }

.btn-secondary {
  background: white;
  color: #0070f3;
  border: 2px solid #0070f3;
  padding: 12px 28px;
  border-radius: 8px;
  text-decoration: none;
  font-weight: 600;
  font-size: 15px;
  transition: all 0.2s;
}

.btn-secondary:hover { background: #f0f7ff; }

Blog List Page

src/app/pages/blog/blog.ts:

import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Blog as BlogService } from '../../services/blog';

@Component({
  selector: 'app-blog',
  imports: [RouterLink],
  templateUrl: './blog.html',
  styleUrl: './blog.css'
})
export class Blog {

  blogService = inject(BlogService);
  posts = this.blogService.allPosts;
}

src/app/pages/blog/blog.html:

<div class="blog-page">
  <h1>All Articles</h1>
  <p class="subtitle">{{ posts().length }} articles published</p>

  <div class="post-grid">
    @for (post of posts(); track post.id) {
      <article class="post-card">
        <span class="category">{{ post.category }}</span>
        <h2>{{ post.title }}</h2>
        <p class="excerpt">{{ post.excerpt }}</p>
        <div class="post-meta">
          <span>{{ post.author }}</span>
          <span>·</span>
          <span>{{ post.readTime }} min read</span>
          <span>·</span>
          <span>{{ post.date }}</span>
        </div>
        <a [routerLink]="['/blog', post.id]" class="read-more">Read Article →</a>
      </article>
    }
  </div>
</div>

src/app/pages/blog/blog.css:

.blog-page {
  max-width: 900px;
  margin: 0 auto;
  padding: 48px 24px;
}

h1 {
  font-size: 40px;
  font-weight: 800;
  color: #1a1a2e;
  margin-bottom: 8px;
}

.subtitle {
  color: #888;
  margin-bottom: 40px;
}

.post-grid {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.post-card {
  background: white;
  padding: 28px;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.06);
  transition: box-shadow 0.2s;
}

.post-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.1); }

.category {
  font-size: 12px;
  font-weight: 700;
  color: #0070f3;
  text-transform: uppercase;
  letter-spacing: 1px;
}

.post-card h2 {
  font-size: 22px;
  color: #1a1a2e;
  margin: 10px 0 8px;
  font-weight: 700;
}

.excerpt {
  color: #666;
  line-height: 1.6;
  font-size: 15px;
  margin-bottom: 16px;
}

.post-meta {
  display: flex;
  gap: 8px;
  font-size: 13px;
  color: #999;
  margin-bottom: 20px;
}

.read-more {
  color: #0070f3;
  font-weight: 600;
  text-decoration: none;
  font-size: 14px;
}

.read-more:hover { text-decoration: underline; }

Post Detail Page

src/app/pages/post-detail/post-detail.ts:

import { Component, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Blog, Post } from '../../services/blog';

@Component({
  selector: 'app-post-detail',
  imports: [RouterLink],
  templateUrl: './post-detail.html',
  styleUrl: './post-detail.css'
})
export class PostDetail implements OnInit {

  private route = inject(ActivatedRoute);
  private blogService = inject(Blog);

  post = signal<Post | null>(null);
  notFound = signal<boolean>(false);

  ngOnInit(): void {
    const id = Number(this.route.snapshot.params['id']);
    const found = this.blogService.getPostById(id);

    if (found) {
      this.post.set(found);
    } else {
      this.notFound.set(true);
    }
  }
}

src/app/pages/post-detail/post-detail.html:

<div class="post-detail">

  @if (notFound()) {
    <div class="not-found">
      <h2>Post not found</h2>
      <a routerLink="/blog">← Back to Blog</a>
    </div>
  }

  @if (post()) {
    <article>
      <a routerLink="/blog" class="back">← Back to Blog</a>

      <header class="post-header">
        <span class="category">{{ post()!.category }}</span>
        <h1>{{ post()!.title }}</h1>
        <div class="meta">
          <span>By {{ post()!.author }}</span>
          <span>·</span>
          <span>{{ post()!.readTime }} min read</span>
          <span>·</span>
          <span>{{ post()!.date }}</span>
        </div>
      </header>

      <div class="post-content">
        <p>{{ post()!.content }}</p>
      </div>
    </article>
  }

</div>

src/app/pages/post-detail/post-detail.css:

.post-detail {
  max-width: 760px;
  margin: 0 auto;
  padding: 48px 24px;
}

.back {
  color: #0070f3;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  display: block;
  margin-bottom: 32px;
}

.post-header { margin-bottom: 40px; }

.category {
  font-size: 12px;
  font-weight: 700;
  color: #0070f3;
  text-transform: uppercase;
  letter-spacing: 1px;
}

h1 {
  font-size: 40px;
  font-weight: 800;
  color: #1a1a2e;
  line-height: 1.2;
  margin: 12px 0;
  letter-spacing: -0.5px;
}

.meta {
  display: flex;
  gap: 8px;
  color: #999;
  font-size: 14px;
}

.post-content p {
  font-size: 17px;
  line-height: 1.8;
  color: #444;
}

.not-found { text-align: center; padding: 80px 0; }
.not-found h2 { color: #333; margin-bottom: 16px; }
.not-found a { color: #0070f3; text-decoration: none; }

Login Page

src/app/pages/login/login.ts:

import { Component, inject, signal } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { Auth } from '../../services/auth';

@Component({
  selector: 'app-login',
  imports: [FormsModule],
  templateUrl: './login.html',
  styleUrl: './login.css'
})
export class Login {

  private authService = inject(Auth);
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  name: string = '';
  password: string = '';
  error = signal<string>('');

  onLogin(): void {
    const success = this.authService.login(this.name, this.password);

    if (success) {
      const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
      this.router.navigateByUrl(returnUrl);
    } else {
      this.error.set('Invalid credentials. Use any name with password "password".');
    }
  }
}

src/app/pages/login/login.html:

<div class="login-page">
  <div class="login-card">
    <h1>Sign In</h1>
    <p class="subtitle">Welcome back to DevBlog</p>

    @if (error()) {
      <div class="error-msg">{{ error() }}</div>
    }

    <div class="field">
      <label>Your Name</label>
      <input type="text" [(ngModel)]="name" placeholder="Rahul Sharma">
    </div>

    <div class="field">
      <label>Password</label>
      <input
        type="password"
        [(ngModel)]="password"
        (keyup.enter)="onLogin()"
        placeholder="Use: password">
    </div>

    <button class="login-btn" (click)="onLogin()">Sign In</button>

    <p class="hint">Hint: any name + password "password"</p>
  </div>
</div>

src/app/pages/login/login.css:

.login-page {
  min-height: calc(100vh - 60px);
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f9fafb;
}

.login-card {
  background: white;
  padding: 40px;
  border-radius: 16px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
  width: 100%;
  max-width: 400px;
}

h1 { font-size: 28px; font-weight: 800; color: #1a1a2e; margin-bottom: 8px; }

.subtitle { color: #888; font-size: 15px; margin-bottom: 28px; }

.error-msg {
  background: #fef2f2;
  border: 1px solid #fecaca;
  color: #dc2626;
  padding: 12px 16px;
  border-radius: 8px;
  font-size: 14px;
  margin-bottom: 20px;
}

.field { margin-bottom: 18px; }

label { display: block; font-size: 13px; font-weight: 600; color: #555; margin-bottom: 6px; }

input {
  width: 100%;
  padding: 11px 14px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  font-size: 15px;
  transition: border-color 0.2s;
  box-sizing: border-box;
}

input:focus { outline: none; border-color: #0070f3; }

.login-btn {
  width: 100%;
  padding: 13px;
  background: #0070f3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  margin-top: 8px;
  transition: background 0.2s;
}

.login-btn:hover { background: #005ac1; }

.hint { text-align: center; color: #bbb; font-size: 13px; margin-top: 16px; }

Dashboard and Child Pages

src/app/pages/dashboard/dashboard.ts:

import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Auth } from '../../services/auth';

@Component({
  selector: 'app-dashboard',
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  templateUrl: './dashboard.html',
  styleUrl: './dashboard.css'
})
export class Dashboard {

  authService = inject(Auth);
  userName = this.authService.userName;
}

src/app/pages/dashboard/dashboard.html:

<div class="dashboard-layout">
  <aside class="sidebar">
    <div class="sidebar-user">
      <div class="avatar">{{ userName()[0] }}</div>
      <span>{{ userName() }}</span>
    </div>

    <nav>
      <a routerLink="my-posts" routerLinkActive="active">📝 My Posts</a>
      <a routerLink="write" routerLinkActive="active">✏️ Write</a>
    </nav>
  </aside>

  <main class="dashboard-main">
    <router-outlet></router-outlet>
  </main>
</div>

src/app/pages/dashboard/dashboard.css:

.dashboard-layout {
  display: flex;
  min-height: calc(100vh - 60px);
}

.sidebar {
  width: 220px;
  background: #1a1a2e;
  padding: 24px 16px;
  flex-shrink: 0;
}

.sidebar-user {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 8px 20px;
  border-bottom: 1px solid #233554;
  margin-bottom: 16px;
  color: #ccd6f6;
  font-size: 14px;
  font-weight: 600;
}

.avatar {
  width: 36px;
  height: 36px;
  background: #0070f3;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: 700;
}

.sidebar nav {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.sidebar nav a {
  display: block;
  padding: 10px 12px;
  color: #8892b0;
  text-decoration: none;
  border-radius: 8px;
  font-size: 14px;
  transition: all 0.2s;
}

.sidebar nav a:hover { color: #ccd6f6; background: rgba(255,255,255,0.05); }
.sidebar nav a.active { color: #64ffda; background: rgba(100,255,218,0.08); }

.dashboard-main { flex: 1; padding: 36px; background: #f9fafb; }

src/app/pages/dashboard/my-posts/my-posts.ts:

import { Component, inject } from '@angular/core';
import { Blog } from '../../../services/blog';
import { Auth } from '../../../services/auth';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-my-posts',
  imports: [RouterLink],
  template: `
    <div>
      <h1>My Posts</h1>
      <div class="posts">
        @for (post of myPosts; track post.id) {
          <div class="post-row">
            <div>
              <h3>{{ post.title }}</h3>
              <p>{{ post.date }} · {{ post.readTime }} min read</p>
            </div>
            <a [routerLink]="['/blog', post.id]">View →</a>
          </div>
        } @empty {
          <p class="empty">No posts yet. Start writing!</p>
        }
      </div>
    </div>
  `,
  styles: [`
    h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 24px; }
    .posts { display: flex; flex-direction: column; gap: 12px; }
    .post-row {
      background: white;
      padding: 18px 20px;
      border-radius: 10px;
      display: flex;
      justify-content: space-between;
      align-items: center;
      box-shadow: 0 1px 4px rgba(0,0,0,0.06);
    }
    h3 { font-size: 16px; color: #1a1a2e; margin-bottom: 4px; }
    p { font-size: 13px; color: #999; }
    a { color: #0070f3; font-size: 14px; font-weight: 600; text-decoration: none; }
    .empty { color: #999; font-size: 15px; }
  `]
})
export class MyPosts {

  private blogService = inject(Blog);
  private authService = inject(Auth);

  get myPosts() {
    return this.blogService.allPosts().filter(
      p => p.author === this.authService.userName()
    );
  }
}

src/app/pages/dashboard/write-post/write-post.ts:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-write-post',
  imports: [FormsModule],
  template: `
    <div>
      <h1>Write a Post</h1>
      <div class="field">
        <label>Title</label>
        <input type="text" [(ngModel)]="title" placeholder="Post title...">
      </div>
      <div class="field">
        <label>Content</label>
        <textarea [(ngModel)]="content" rows="10" placeholder="Write your post..."></textarea>
      </div>
      <button class="publish-btn" [disabled]="!title || !content">Publish Post</button>
    </div>
  `,
  styles: [`
    h1 { font-size: 26px; font-weight: 700; color: #1a1a2e; margin-bottom: 24px; }
    .field { margin-bottom: 18px; }
    label { display: block; font-size: 13px; font-weight: 600; color: #555; margin-bottom: 6px; }
    input, textarea {
      width: 100%;
      padding: 12px 14px;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      font-size: 15px;
      font-family: inherit;
      box-sizing: border-box;
    }
    input:focus, textarea:focus { outline: none; border-color: #0070f3; }
    textarea { resize: vertical; }
    .publish-btn {
      background: #0070f3;
      color: white;
      border: none;
      padding: 12px 28px;
      border-radius: 8px;
      font-size: 15px;
      font-weight: 600;
      cursor: pointer;
    }
    .publish-btn:disabled { background: #ccc; cursor: not-allowed; }
  `]
})
export class WritePost {
  title: string = '';
  content: string = '';
}

Not Found Page

src/app/pages/not-found/not-found.ts:

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';

@Component({
  selector: 'app-not-found',
  imports: [RouterLink],
  template: `
    <div class="not-found">
      <h1>404</h1>
      <p>This page does not exist.</p>
      <a routerLink="/">← Go Home</a>
    </div>
  `,
  styles: [`
    .not-found { text-align: center; padding: 100px 24px; }
    h1 { font-size: 96px; font-weight: 800; color: #e0e0e0; line-height: 1; margin-bottom: 16px; }
    p { font-size: 18px; color: #888; margin-bottom: 24px; }
    a { color: #0070f3; font-weight: 600; text-decoration: none; font-size: 15px; }
  `]
})
export class NotFound { }

App Component

src/app/app.ts:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Navbar } from './components/navbar/navbar';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, Navbar],
  template: `
    <app-navbar></app-navbar>
    <router-outlet></router-outlet>
  `
})
export class App { }

src/styles.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

body {
  background: #f9fafb;
  color: #1a1a2e;
}

Run ng serve -o. Your complete blog application is running with:

Home page at /. Blog list at /blog. Individual posts at /blog/1, /blog/2, etc. Protected dashboard at /dashboard with nested /dashboard/my-posts and /dashboard/write. Login at /login with redirect to the page the user was trying to reach. 404 page for any unknown URL. All routes lazy loaded — each page's code downloads only when navigated to. Active nav links highlighted with RouterLinkActive. Programmatic navigation after login using Router.navigateByUrl.


Phase 6 — Complete Summary

Here is everything you learned in this phase.

What routing is — Angular reads the URL and renders different components without page reloads. The URL updates so users can share, bookmark, and navigate with browser history.

Defining routes — Routes are objects in app.routes.ts. Each route maps a path string to a component. The ** wildcard catches all unmatched URLs. redirectTo redirects one path to another.

router-outlet — The placeholder in your template where matched components render. Root outlet in app.html. Child outlets inside parent routed components.

RouterLink — Use routerLink instead of href for all internal navigation. Never use href inside Angular apps.

RouterLinkActive — Adds a CSS class when the route is active. Use [routerLinkActiveOptions]="{ exact: true }" for the home route.

Programmatic navigationinject(Router) then this.router.navigate(['/path']) to navigate from TypeScript code. navigateByUrl for full URL strings.

Route parameters:id in the path creates a parameter. Read it with inject(ActivatedRoute) then this.route.snapshot.params['id']. Always convert to the right type — params are always strings.

Query parameters?key=value in URLs. Set with queryParams option on navigate. Read with this.route.snapshot.queryParams['key'].

Nested routeschildren array on a parent route. Parent must have its own <router-outlet>. Child routerLink without leading / is relative to the parent path.

Route guards — Functions that run before navigation. canActivate blocks unauthorized access. canActivateChild protects all children at once. canDeactivate warns about unsaved changes. Return true to allow, false to block, or redirect and return false.

Lazy loadingloadComponent with a dynamic import. Component code only downloads when the route is first visited. Dramatically improves initial load performance.

Route data — Static data attached to routes with the data property. Read with this.route.snapshot.data['key']. Useful for page titles and breadcrumbs.


What's Next — Phase 7

In Phase 7 we cover Forms in complete depth — both approaches Angular provides:

Template-driven forms — simple forms where most logic is in the HTML. Reactive forms — complex forms where all logic is in TypeScript. Form validation — built-in validators and custom validators. Showing error messages — displaying the right message at the right time. Dynamic forms — adding and removing form fields at runtime. Async validators — checking things like username availability against an API.

Phase 7 — Forms

Chapter 1 — Why Forms Are a Big Deal 1.1 — Forms Are Everywhere Every meaningful interaction a user has with a web application goes through ...