Angular's reactive forms and Bootstrap 5's validation styles are a natural fit. Bootstrap provides the CSS (is-valid, is-invalid, valid-feedback, invalid-feedback), Angular provides the logic. Here's how to wire them together properly.
Setup
Make sure Bootstrap is in your angular.json:
"styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css"], "scripts": ["node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"]
Import ReactiveFormsModule in your component:
import { ReactiveFormsModule } from '@angular/forms' @Component({ standalone: true, imports: [ReactiveFormsModule, CommonModule], // ... })
Basic Login Form with Validation
// login.component.ts import { Component } from '@angular/core' import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms' import { CommonModule } from '@angular/common' @Component({ selector: 'app-login', standalone: true, imports: [ReactiveFormsModule, CommonModule], templateUrl: './login.component.html' }) export class LoginComponent { form: FormGroup constructor(private fb: FormBuilder) { this.form = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]] }) } // Helper to check if field should show invalid state isInvalid(field: string): boolean { const control = this.form.get(field) return !!(control?.invalid && control?.touched) } isValid(field: string): boolean { const control = this.form.get(field) return !!(control?.valid && control?.touched) } onSubmit() { this.form.markAllAsTouched() if (this.form.invalid) return console.log('Form valid:', this.form.value) } }
<!-- login.component.html --> <form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate> <div class="mb-3"> <label class="form-label">Email Address</label> <input type="email" class="form-control" formControlName="email" [class.is-invalid]="isInvalid('email')" [class.is-valid]="isValid('email')" placeholder="name@example.com"> <div class="invalid-feedback"> <ng-container *ngIf="form.get('email')?.hasError('required')"> Email is required. </ng-container> <ng-container *ngIf="form.get('email')?.hasError('email')"> Please enter a valid email address. </ng-container> </div> <div class="valid-feedback">Looks good!</div> </div> <div class="mb-3"> <label class="form-label">Password</label> <input type="password" class="form-control" formControlName="password" [class.is-invalid]="isInvalid('password')" [class.is-valid]="isValid('password')" placeholder="Min 8 characters"> <div class="invalid-feedback"> <ng-container *ngIf="form.get('password')?.hasError('required')"> Password is required. </ng-container> <ng-container *ngIf="form.get('password')?.hasError('minlength')"> Password must be at least 8 characters. </ng-container> </div> </div> <button type="submit" class="btn btn-primary w-100">Sign In</button> </form>
Password Confirmation Validator
The cross-field validation pattern — comparing password and confirm password:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms' export const passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => { const password = group.get('password')?.value const confirm = group.get('confirmPassword')?.value return password === confirm ? null : { passwordMismatch: true } }
Apply to the FormGroup:
this.form = this.fb.group({ password: ['', [Validators.required, Validators.minLength(8)]], confirmPassword: ['', Validators.required] }, { validators: passwordMatchValidator })
Show the error:
<div class="invalid-feedback d-block" *ngIf="form.hasError('passwordMismatch') && form.get('confirmPassword')?.touched"> Passwords do not match. </div>
Note: cross-field errors are on the FormGroup, not the individual control, so use form.hasError('passwordMismatch') not form.get('confirmPassword')?.hasError(...).
Custom Validator
Create a validator function for custom rules — username availability, specific format requirements:
// username.validator.ts import { AbstractControl, ValidationErrors } from '@angular/forms' export function noSpacesValidator(control: AbstractControl): ValidationErrors | null { if (control.value && /\s/.test(control.value)) { return { noSpaces: true } } return null } export function usernameFormatValidator(control: AbstractControl): ValidationErrors | null { const pattern = /^[a-zA-Z0-9_]{3,20}$/ if (control.value && !pattern.test(control.value)) { return { invalidUsername: true } } return null }
Use:
username: ['', [Validators.required, usernameFormatValidator]]
<div class="invalid-feedback"> <ng-container *ngIf="form.get('username')?.hasError('invalidUsername')"> Username must be 3-20 characters, letters, numbers and underscores only. </ng-container> </div>
Reusable Validation Helper
Typing form.get('field')?.invalid && form.get('field')?.touched everywhere gets old. Create a helper method:
// In your component get f() { return this.form.controls } fieldError(field: string): string | null { const control = this.f[field] if (!control || !control.touched || !control.errors) return null const errors = control.errors if (errors['required']) return 'This field is required' if (errors['email']) return 'Please enter a valid email' if (errors['minlength']) return `Minimum ${errors['minlength'].requiredLength} characters` if (errors['maxlength']) return `Maximum ${errors['maxlength'].requiredLength} characters` if (errors['pattern']) return 'Invalid format' return 'Invalid value' }
<input class="form-control" formControlName="email" [class.is-invalid]="f['email'].invalid && f['email'].touched"> <div class="invalid-feedback">{{ fieldError('email') }}</div>
Much cleaner templates.
Real-Time Validation
For real-time feedback as the user types, remove the touched check — but only after the user has started filling in the field:
shouldShowError(field: string): boolean { const control = this.form.get(field) // Show error if dirty (user typed something) and invalid return !!(control?.dirty && control?.invalid) }
This gives immediate feedback as the user types rather than waiting for them to leave the field.
Frequently Asked Questions
Related
Need a Full Angular + Bootstrap Admin Dashboard?
Marvel Angular Dashboard — Angular 21 + Bootstrap 5 with 50+ components and dark mode.
Browse Templates →Use code FIRST30 for 30% off.