Building Single Page Applications (SPA) with ASP.NET Core and Angular
Leveraging Modern Web Development with .NET and Angular
Single Page Applications have revolutionized web development by providing seamless, responsive user experiences. When you combine the power of ASP.NET Core's robust backend capabilities with Angular's dynamic frontend framework, you can create sophisticated applications that meet modern user expectations.
This comprehensive guide will walk you through the process of building, optimizing, and deploying SPAs using these complementary technologies.
Introduction
The web development landscape continues to evolve at a rapid pace. Users now expect applications to be fast, responsive, and feature-rich, with minimal page reloads and instant feedback. Single Page Applications (SPAs) have emerged as the solution to these demands, offering an experience more akin to desktop applications than traditional websites.
ASP.NET Core and Angular represent a powerful combination for building SPAs. ASP.NET Core provides a cross-platform, high-performance backend framework with excellent API capabilities, while Angular delivers a comprehensive frontend solution with powerful data binding, component-based architecture, and robust tooling.
In this article, we'll explore how these technologies work together, guide you through setting up a full-stack SPA project, and share best practices for architecture, security, and deployment. Whether you're new to SPAs or looking to enhance your existing knowledge, this guide will provide valuable insights into modern web development with ASP.NET Core and Angular.
Understanding SPAs: Benefits and Challenges
Before diving into implementation details, let's understand what makes SPAs different from traditional multi-page applications and why they've become so popular.
What Is a Single Page Application?
A Single Page Application is a web application that loads a single HTML page and dynamically updates content as users interact with the app. Instead of loading entire new pages from the server, SPAs use JavaScript to manipulate the DOM and fetch data asynchronously, creating a more fluid user experience.
The core characteristics of SPAs include:
Minimal page reloads: Content updates without refreshing the page
Client-side routing: Navigation between views happens without server round-trips
Asynchronous data loading: Data is fetched as needed, often in the background
Rich interactivity: More responsive user interfaces with immediate feedback
Benefits of the SPA Approach
SPAs offer numerous advantages that make them attractive for modern web applications:
Enhanced User Experience: The most compelling reason to adopt SPAs is the improved user experience they provide. With faster interactions and smoother transitions, users perceive your application as more responsive and engaging.
Reduced Server Load: Since the server only sends complete HTML pages on the initial request, subsequent interactions typically involve smaller data transfers, reducing server processing requirements.
Separation of Concerns: SPAs naturally encourage a clear separation between frontend and backend, with the backend focusing on providing robust APIs and the frontend handling presentation and user interaction.
Offline Capabilities: Many SPA frameworks support service workers and other technologies that enable offline functionality, allowing applications to work even when connectivity is limited.
Code Reusability: Component-based architectures in modern SPA frameworks promote reusable UI elements, improving development efficiency and consistency.
Challenges to Consider
While SPAs offer significant benefits, they also come with challenges that developers should be aware of:
Initial Load Time: SPAs often require downloading substantial JavaScript bundles upfront, which can lead to longer initial load times compared to traditional websites.
SEO Considerations: Search engines have historically struggled with JavaScript-heavy applications, although this has improved significantly in recent years.
Browser History Management: Implementing proper routing and history management requires additional effort to ensure expected browser navigation behavior.
State Management Complexity: As applications grow, managing client-side state can become complex and require specialized solutions.
Security Concerns: SPAs face unique security challenges, particularly with authentication mechanisms and protecting API endpoints.
Understanding these trade-offs is crucial when deciding whether an SPA architecture is appropriate for your project. For many modern web applications, the benefits outweigh the challenges, especially when using mature frameworks like Angular with ASP.NET Core.
Setting Up Your Development Environment
Before we start building our SPA, we need to set up a proper development environment. Here's what you'll need:
Prerequisites
.NET 8 SDK: Download and install the latest .NET SDK from the official Microsoft website.
Node.js and npm: Angular requires Node.js and npm. Download from Node.js official website.
Angular CLI: Install globally using npm:
npm install -g @angular/cli
IDE: Visual Studio 2022 or Visual Studio Code with C# and Angular extensions.
For VS Code, recommended extensions include:
C# Dev Kit
Angular Language Service
ESLint
Prettier
Git: For version control
Creating a New ASP.NET Core and Angular Project
Microsoft provides a convenient template for creating ASP.NET Core applications with Angular. Let's use it to scaffold our project:
dotnet new angular -o MyAngularSpaProject
This command creates a new project with both ASP.NET Core and Angular pre-configured. The template sets up:
An ASP.NET Core application with controllers for API endpoints
An Angular application in the ClientApp directory
Proxy configuration for development
Build integration between .NET and Angular
Navigate to your project directory and run it:
cd MyAngularSpaProject
dotnet run
Your browser should open automatically, showing the default Angular application running with ASP.NET Core backend.
Understanding the Project Structure
Let's examine the generated project structure:
MyAngularSpaProject/
├── ClientApp/ # Angular application
│ ├── src/ # Angular source code
│ ├── angular.json # Angular CLI configuration
│ ├── package.json # npm dependencies
│ └── ...
├── Controllers/ # API controllers
├── Pages/ # Razor pages (if any)
├── Program.cs # ASP.NET Core startup
├── appsettings.json # Backend configuration
├── MyAngularSpaProject.csproj # Project file
└── ...
This structure separates the frontend (ClientApp) from the backend (everything else), making it easier to work on either part independently.
Building the Backend with ASP.NET Core
Now that we have our project set up, let's focus on building a robust backend API with ASP.NET Core.
Creating API Controllers
API controllers are the backbone of your SPA's server-side functionality. They handle HTTP requests, process data, and return responses, typically in JSON format.
Here's an example of a simple API controller for managing products:
using Microsoft.AspNetCore.Mvc;
using MyAngularSpaProject.Models;
using MyAngularSpaProject.Services;
namespace MyAngularSpaProject.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductService productService, ILogger<ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetProducts()
{
try
{
var products = await _productService.GetAllProductsAsync();
return Ok(products);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving products");
return StatusCode(500, "An error occurred while retrieving products.");
}
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
var product = await _productService.GetProductByIdAsync(id);
if (product == null)
{
return NotFound();
}
return Ok(product);
}
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var createdProduct = await _productService.CreateProductAsync(product);
return CreatedAtAction(nameof(GetProduct), new { id = createdProduct.Id }, createdProduct);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, Product product)
{
if (id != product.Id)
{
return BadRequest();
}
var updated = await _productService.UpdateProductAsync(product);
if (!updated)
{
return NotFound();
}
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
var deleted = await _productService.DeleteProductAsync(id);
if (!deleted)
{
return NotFound();
}
return NoContent();
}
}
Setting Up Entity Framework Core
Most SPAs need persistent data storage. Entity Framework Core is a powerful ORM that integrates seamlessly with ASP.NET Core:
Add the necessary packages:
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
Create your data models:
namespace MyAngularSpaProject.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public bool IsAvailable { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
Set up your DbContext:
using Microsoft.EntityFrameworkCore;
using MyAngularSpaProject.Models;
namespace MyAngularSpaProject.Data;
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Product> Products { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Seed data or configure relationships here
modelBuilder.Entity<Product>().HasData(
new Product
{
Id = 1,
Name = "Sample Product 1",
Description = "This is a sample product",
Price = 19.99m,
IsAvailable = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
}
);
}
}
Register your DbContext in Program.cs:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
Add your connection string to appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyAngularSpaProject;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}
Create and run migrations:
dotnet ef migrations add InitialCreate
dotnet ef database update
Implementing Services
To separate business logic from controllers, create service classes:
namespace MyAngularSpaProject.Services;
public interface IProductService
{
Task<IEnumerable<Product>> GetAllProductsAsync();
Task<Product?> GetProductByIdAsync(int id);
Task<Product> CreateProductAsync(Product product);
Task<bool> UpdateProductAsync(Product product);
Task<bool> DeleteProductAsync(int id);
}
public class ProductService : IProductService
{
private readonly ApplicationDbContext _context;
public ProductService(ApplicationDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Product>> GetAllProductsAsync()
{
return await _context.Products.ToListAsync();
}
public async Task<Product?> GetProductByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
public async Task<Product> CreateProductAsync(Product product)
{
product.CreatedAt = DateTime.UtcNow;
product.UpdatedAt = DateTime.UtcNow;
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product;
}
public async Task<bool> UpdateProductAsync(Product product)
{
var existingProduct = await _context.Products.FindAsync(product.Id);
if (existingProduct == null)
{
return false;
}
existingProduct.Name = product.Name;
existingProduct.Description = product.Description;
existingProduct.Price = product.Price;
existingProduct.IsAvailable = product.IsAvailable;
existingProduct.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product == null)
{
return false;
}
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return true;
}
}
Register your service in Program.cs:
builder.Services.AddScoped<IProductService, ProductService>();
API Documentation with Swagger
Adding Swagger documentation to your API makes it easier to test and understand:
Add the Swagger package:
dotnet add package Swashbuckle.AspNetCore
Configure Swagger in Program.cs:
// Add Swagger services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo {
Title = "My Angular SPA API",
Version = "v1",
Description = "API for Angular SPA demo application"
});
});
// In the app configuration section:
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "My Angular SPA API v1"));
}
Navigate to /swagger
in your browser to see and test your API endpoints.
Building the Frontend with Angular
Now that our backend is set up, let's explore how to build a robust SPA frontend with Angular.
Angular Project Structure
The default Angular structure in the ClientApp directory includes:
ClientApp/
├── src/
│ ├── app/ # Application code
│ │ ├── components/ # Reusable UI components
│ │ ├── models/ # TypeScript interfaces
│ │ ├── services/ # Data and utility services
│ │ ├── app.component.ts # Root component
│ │ └── app.module.ts # Main module
│ ├── assets/ # Static files
│ ├── environments/ # Environment configurations
│ ├── index.html # Main HTML file
│ └── main.ts # Application entry point
└── angular.json # Angular CLI configuration
Creating Angular Components
Components are the building blocks of Angular applications. Let's create components for our product management system:
bash
cd ClientApp
ng generate component components/product-list
ng generate component components/product-detail
ng generate component components/product-form
Implementing Angular Services
Services handle data operations and business logic. Create a service for product operations:
ng generate service services/product
Implement the service:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from '../models/product.model';
@Injectable({
providedIn: 'root'
})
export class ProductService {
private apiUrl = 'api/products';
constructor(private http: HttpClient) { }
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
}
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`${this.apiUrl}/${id}`);
}
createProduct(product: Product): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}
updateProduct(product: Product): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/${product.id}`, product);
}
deleteProduct(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
Create the Product model interface:
// src/app/models/product.model.ts
export interface Product {
id?: number;
name: string;
description: string;
price: number;
isAvailable: boolean;
createdAt?: Date;
updatedAt?: Date;
}
Implementing Components
Now implement the components we created earlier. Here's an example for the product list component:
// product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { ProductService } from '../../services/product.service';
import { Product } from '../../models/product.model';
@Component({
selector: 'app-product-list',
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.css']
})
export class ProductListComponent implements OnInit {
products: Product[] = [];
loading = true;
error = '';
constructor(private productService: ProductService) { }
ngOnInit(): void {
this.loadProducts();
}
loadProducts(): void {
this.loading = true;
this.productService.getProducts()
.subscribe({
next: (data) => {
this.products = data;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load products. Please try again later.';
this.loading = false;
console.error('Error fetching products:', err);
}
});
}
deleteProduct(id: number): void {
if (confirm('Are you sure you want to delete this product?')) {
this.productService.deleteProduct(id).subscribe({
next: () => {
this.products = this.products.filter(p => p.id !== id);
},
error: (err) => {
console.error('Error deleting product:', err);
alert('Failed to delete product. Please try again later.');
}
});
}
}
}
And its corresponding template:
<!-- product-list.component.html -->
<div class="container">
<h2>Products</h2>
<div class="mb-3">
<a [routerLink]="['/products/new']" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Product
</a>
</div>
<div *ngIf="loading" class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div *ngIf="error" class="alert alert-danger">
{{ error }}
</div>
<div *ngIf="!loading && !error && products.length === 0" class="alert alert-info">
No products found. Click "Add New Product" to create one.
</div>
<div *ngIf="!loading && products.length > 0" class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
<th>Available</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let product of products">
<td>{{ product.name }}</td>
<td>{{ product.price | currency }}</td>
<td>
<span *ngIf="product.isAvailable" class="badge bg-success">Yes</span>
<span *ngIf="!product.isAvailable" class="badge bg-danger">No</span>
</td>
<td>
<div class="btn-group">
<a [routerLink]="['/products', product.id]" class="btn btn-sm btn-info">
<i class="bi bi-eye"></i> View
</a>
<a [routerLink]="['/products/edit', product.id]" class="btn btn-sm btn-warning">
<i class="bi bi-pencil"></i> Edit
</a>
<button (click)="deleteProduct(product.id!)" class="btn btn-sm btn-danger">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
Setting Up Angular Routing
Configure routing to navigate between components without page reloads:
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ProductListComponent } from './components/product-list/product-list.component';
import { ProductDetailComponent } from './components/product-detail/product-detail.component';
import { ProductFormComponent } from './components/product-form/product-form.component';
import { HomeComponent } from './components/home/home.component';
const routes: Routes = [
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'products', component: ProductListComponent },
{ path: 'products/new', component: ProductFormComponent },
{ path: 'products/edit/:id', component: ProductFormComponent },
{ path: 'products/:id', component: ProductDetailComponent },
{ path: '**', redirectTo: '' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Update app.module.ts to include the routing module:
// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomeComponent } from './components/home/home.component';
import { ProductListComponent } from './components/product-list/product-list.component';
import { ProductDetailComponent } from './components/product-detail/product-detail.component';
import { ProductFormComponent } from './components/product-form/product-form.component';
@NgModule({
declarations: [
AppComponent,
HomeComponent,
ProductListComponent,
ProductDetailComponent,
ProductFormComponent
],
imports: [
BrowserModule,
HttpClientModule,
FormsModule,
ReactiveFormsModule,
AppRoutingModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Authentication and Authorization
Security is crucial for SPAs. Let's implement authentication and authorization using ASP.NET Core Identity and JWT tokens.
Setting Up ASP.NET Core Identity
Add the required packages:
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Configure Identity in Program.cs:
// Add Identity services
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
// Configure JWT authentication
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var key = Encoding.ASCII.GetBytes(jwtSettings["SecretKey"]);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = true,
ValidateAudience = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
ClockSkew = TimeSpan.Zero
};
});
Create an authentication controller:
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IConfiguration _configuration;
public AuthController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IConfiguration configuration)
{
_userManager = userManager;
_signInManager = signInManager;
_configuration = configuration;
}
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterDto model)
{
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
FirstName = model.FirstName,
LastName = model.LastName
};
var result = await _userManager.CreateAsync(user, model.Password);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}
return Ok(new { message = "Registration successful" });
}
[HttpPost("login")]
public async Task<IActionResult> Login(LoginDto model)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null)
{
return Unauthorized(new { message = "Invalid email or password" });
}
var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, false);
if (!result.Succeeded)
{
return Unauthorized(new { message = "Invalid email or password" });
}
var token = GenerateJwtToken(user);
return Ok(new
{
token,
user = new
{
id = user.Id,
email = user.Email,
firstName = user.FirstName,
lastName = user.LastName
}
});
}
private string GenerateJwtToken(ApplicationUser user)
{
var jwtSettings = _configuration.GetSection("JwtSettings");
var key = Encoding.ASCII.GetBytes(jwtSettings["SecretKey"]);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.Email, user.Email)
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddDays(7),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
Issuer = jwtSettings["Issuer"],
Audience = jwtSettings["Audience"]
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
}
Implementing Authentication in Angular
Create an authentication service:
ng generate service services/auth
Implement the authentication service:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, tap } from 'rxjs';
import { Router } from '@angular/router';
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
}
export interface AuthResponse {
token: string;
user: User;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
private tokenKey = 'auth_token';
private userKey = 'user';
constructor(private http: HttpClient, private router: Router) {
this.loadStoredUser();
}
private loadStoredUser(): void {
const storedUser = localStorage.getItem(this.userKey);
if (storedUser) {
this.currentUserSubject.next(JSON.parse(storedUser));
}
}
register(email: string, password: string, firstName: string, lastName: string): Observable<any> {
return this.http.post<any>('api/auth/register', {
email,
password,
firstName,
lastName
});
}
login(email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('api/auth/login', { email, password })
.pipe(
tap(response => {
localStorage.setItem(this.tokenKey, response.token);
localStorage.setItem(this.userKey, JSON.stringify(response.user));
this.currentUserSubject.next(response.user);
})
);
}
logout(): void {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.userKey);
this.currentUserSubject.next(null);
this.router.navigate(['/login']);
}
getToken(): string | null {
return localStorage.getItem(this.tokenKey);
}
isAuthenticated(): boolean {
return this.getToken() !== null;
}
}
Create an HTTP interceptor for authentication:
ng generate interceptor interceptors/auth
Implement the interceptor:
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService, private router: Router) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getToken();
if (token) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
this.authService.logout();
this.router.navigate(['/login']);
}
return throwError(() => error);
})
);
}
}
Create login and register components:
ng generate component components/login
ng generate component components/register
Implement auth guards to protect routes:
ng generate guard guards/auth
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (this.authService.isAuthenticated()) {
return true;
}
// Redirect to login page
return this.router.createUrlTree(['/login']);
}
}
Update your routes to use the guard:
const routes: Routes = [
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{
path: 'products',
component: ProductListComponent,
canActivate: [AuthGuard]
},
{
path: 'products/new',
component: ProductFormComponent,
canActivate: [AuthGuard]
},
{
path: 'products/edit/:id',
component: ProductFormComponent,
canActivate: [AuthGuard]
},
{
path: 'products/:id',
component: ProductDetailComponent,
canActivate: [AuthGuard]
},
{ path: '**', redirectTo: '' }
];
Advanced Features for Enterprise SPAs
Now let's explore some advanced features that will make your ASP.NET Core and Angular SPA enterprise-ready.
State Management with NgRx
For larger applications, managing state becomes complex. NgRx provides Redux-style state management for Angular applications:
Install NgRx:
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools
Create your state structure:
// state/product/product.state.ts
import { EntityState, createEntityAdapter } from '@ngrx/entity';
import { Product } from '../../models/product.model';
export const productAdapter = createEntityAdapter<Product>({
selectId: (product: Product) => product.id!
});
export interface ProductState extends EntityState<Product> {
loading: boolean;
error: string | null;
selectedProductId: number | null;
}
export const initialProductState: ProductState = productAdapter.getInitialState({
loading: false,
error: null,
selectedProductId: null
});
Define actions:
// state/product/product.actions.ts
import { createAction, props } from '@ngrx/store';
import { Product } from '../../models/product.model';
export const loadProducts = createAction(
'[Product] Load Products'
);
export const loadProductsSuccess = createAction(
'[Product] Load Products Success',
props<{ products: Product[] }>()
);
export const loadProductsFailure = createAction(
'[Product] Load Products Failure',
props<{ error: string }>()
);
// Add more actions for CRUD operations
Create reducers:
// state/product/product.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { productAdapter, initialProductState } from './product.state';
import * as ProductActions from './product.actions';
export const productReducer = createReducer(
initialProductState,
on(ProductActions.loadProducts, state => ({
...state,
loading: true,
error: null
})),
on(ProductActions.loadProductsSuccess, (state, { products }) =>
productAdapter.setAll(products, {
...state,
loading: false
})
),
on(ProductActions.loadProductsFailure, (state, { error }) => ({
...state,
loading: false,
error
}))
// Handle other actions
);
Implement effects:
// state/product/product.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
import { ProductService } from '../../services/product.service';
import * as ProductActions from './product.actions';
@Injectable()
export class ProductEffects {
loadProducts$ = createEffect(() => this.actions$.pipe(
ofType(ProductActions.loadProducts),
mergeMap(() => this.productService.getProducts().pipe(
map(products => ProductActions.loadProductsSuccess({ products })),
catchError(error => of(ProductActions.loadProductsFailure({
error: error.message || 'Failed to load products'
})))
))
));
constructor(
private actions$: Actions,
private productService: ProductService
) {}
}
Create selectors:
// state/product/product.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { productAdapter, ProductState } from './product.state';
export const selectProductState = createFeatureSelector<ProductState>('products');
export const {
selectAll: selectAllProducts,
selectEntities: selectProductEntities,
selectIds: selectProductIds,
selectTotal: selectTotalProducts
} = productAdapter.getSelectors(selectProductState);
export const selectProductsLoading = createSelector(
selectProductState,
state => state.loading
);
export const selectProductsError = createSelector(
selectProductState,
state => state.error
);
export const selectSelectedProductId = createSelector(
selectProductState,
state => state.selectedProductId
);
export const selectSelectedProduct = createSelector(
selectProductEntities,
selectSelectedProductId,
(entities, selectedId) => selectedId ? entities[selectedId] : null
);
Progressive Web App (PWA) Support
Transform your SPA into a Progressive Web App with offline capabilities:
Add Angular PWA support:
ng add @angular/pwa
Customize the generated manifest.webmanifest and icons in the assets directory.
Configure the service worker in app.module.ts:
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
@NgModule({
imports: [
// other imports...
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000'
})
]
})
export class AppModule { }
Add an update notification service:
import { Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root'
})
export class UpdateService {
constructor(private swUpdate: SwUpdate, private snackBar: MatSnackBar) {
this.swUpdate.versionUpdates.subscribe(event => {
if (event.type === 'VERSION_READY') {
const snack = this.snackBar.open('New version available', 'Reload', {
duration: 6000,
horizontalPosition: 'center',
verticalPosition: 'bottom'
});
snack.onAction().subscribe(() => {
window.location.reload();
});
}
});
}
}
Server-Side Rendering (SSR) with Angular Universal
Improve performance and SEO with server-side rendering:
Add Angular Universal to your project:
ng add @nguniversal/express-engine
This command creates server.ts and other necessary files. Update your ASP.NET Core project to serve the pre-rendered content: In Program.cs:
// Configure the HTTP request pipeline
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
// Serve pre-rendered Angular content
app.MapWhen(ctx => !ctx.Request.Path.StartsWithSegments("/api"), appBuilder =>
{
appBuilder.UseStaticFiles();
appBuilder.UseSpaStaticFiles();
appBuilder.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (app.Environment.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "serve:ssr");
}
});
});
app.MapControllers(); // API endpoints
Form Management with Reactive Forms
Angular's Reactive Forms provide a robust way to manage form state and validation:
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ProductService } from '../../services/product.service';
import { Product } from '../../models/product.model';
@Component({
selector: 'app-product-form',
templateUrl: './product-form.component.html',
styleUrls: ['./product-form.component.css']
})
export class ProductFormComponent implements OnInit {
productForm: FormGroup;
isEditMode = false;
productId?: number;
loading = false;
submitted = false;
constructor(
private fb: FormBuilder,
private productService: ProductService,
private route: ActivatedRoute,
private router: Router
) {
this.productForm = this.createForm();
}
ngOnInit(): void {
this.productId = this.route.snapshot.params['id'];
this.isEditMode = !!this.productId;
if (this.isEditMode) {
this.loading = true;
this.productService.getProduct(this.productId!).subscribe({
next: (product) => {
this.productForm.patchValue(product);
this.loading = false;
},
error: (error) => {
console.error('Error loading product:', error);
this.loading = false;
// Handle error (e.g., redirect to list)
}
});
}
}
createForm(): FormGroup {
return this.fb.group({
name: ['', [Validators.required, Validators.maxLength(100)]],
description: ['', Validators.maxLength(500)],
price: [0, [Validators.required, Validators.min(0.01)]],
isAvailable: [true]
});
}
get f() { return this.productForm.controls; }
onSubmit(): void {
this.submitted = true;
if (this.productForm.invalid) {
return;
}
this.loading = true;
const productData: Product = this.productForm.value;
if (this.isEditMode) {
productData.id = this.productId;
this.productService.updateProduct(productData).subscribe({
next: () => {
this.router.navigate(['/products']);
},
error: (error) => {
console.error('Error updating product:', error);
this.loading = false;
// Handle error
}
});
} else {
this.productService.createProduct(productData).subscribe({
next: () => {
this.router.navigate(['/products']);
},
error: (error) => {
console.error('Error creating product:', error);
this.loading = false;
// Handle error
}
});
}
}
}
Deployment Strategies
Deploying SPAs requires consideration of both frontend and backend components.
Building for Production
Prepare your Angular app for production:
cd ClientApp
ng build --prod
Publish your ASP.NET Core application:
dotnet publish -c Release
Azure Deployment
Azure App Service is a great option for hosting ASP.NET Core and Angular SPAs:
Create an Azure App Service and SQL Database.
Configure continuous deployment from your Git repository or Azure DevOps.
Set up environment variables for your production environment:
{
"ConnectionStrings": {
"DefaultConnection": "Your-Production-Connection-String"
},
"JwtSettings": {
"SecretKey": "Your-Production-Secret-Key",
"Issuer": "your-production-issuer",
"Audience": "your-production-audience"
}
}
Docker Containerization
For more complex deployments, containerization with Docker provides consistency across environments:
Create a Dockerfile in your project root:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyAngularSpaProject.csproj", "."]
RUN dotnet restore "MyAngularSpaProject.csproj"
COPY . .
# Install Node.js
RUN apt-get update && \
apt-get install -y curl && \
curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs
# Build Angular app
WORKDIR /src/ClientApp
RUN npm install
RUN npm run build -- --prod
# Build .NET app
WORKDIR /src
RUN dotnet build "MyAngularSpaProject.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "MyAngularSpaProject.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyAngularSpaProject.dll"]
Build and run your Docker container:
docker build -t myangularspa .
docker run -p 8080:80 myangularspa
Performance Optimization
Optimizing performance is crucial for providing a good user experience.
Lazy Loading Angular Modules
Lazy loading reduces initial bundle size by loading features on demand:
Organize your application into feature modules:
ng generate module features/products --routing
Move your product components into this module.
Update the app-routing.module.ts to use lazy loading:
const routes: Routes = [
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{
path: 'products',
loadChildren: () => import('./features/products/products.module').then(m => m.ProductsModule),
canActivate: [AuthGuard]
},
{ path: '**', redirectTo: '' }
];
Optimizing API Performance
Improve backend performance with these techniques:
API Response Caching:
builder.Services.AddResponseCaching();
// In the middleware pipeline
app.UseResponseCaching();
// On controller actions
[HttpGet]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public async Task<ActionResult<IEnumerable<Product>>> GetProducts()
{
// Implementation
}
Entity Framework Performance:
// Use AsNoTracking for read-only queries
public async Task<IEnumerable<Product>> GetAllProductsAsync()
{
return await _context.Products.AsNoTracking().ToListAsync();
}
// Add pagination
public async Task<IEnumerable<Product>> GetProductsPagedAsync(int page, int pageSize)
{
return await _context.Products
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
API Compression:
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
app.UseResponseCompression();
Angular Performance Tips
OnPush Change Detection:
@Component({
selector: 'app-product-card',
templateUrl: './product-card.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent {
@Input() product: Product;
}
Virtual Scrolling for Large Lists:
<cdk-virtual-scroll-viewport itemSize="50" class="products-viewport">
<div *cdkVirtualFor="let product of products" class="product-item">
<app-product-card [product]="product"></app-product-card>
</div>
</cdk-virtual-scroll-viewport>
Track By Function for ngFor:
<div *ngFor="let product of products; trackBy: trackByProductId">
<!-- Product content -->
</div>
trackByProductId(index: number, product: Product): number {
return product.id;
}
Testing Your SPA
Comprehensive testing ensures your application works correctly across different scenarios.
Backend Testing with xUnit
Create a test project:
dotnet new xunit -o MyAngularSpaProject.Tests
cd MyAngularSpaProject.Tests
dotnet add reference ../MyAngularSpaProject.csproj
Write controller tests:
public class ProductsControllerTests
{
private readonly Mock<IProductService> _mockProductService;
private readonly Mock<ILogger<ProductsController>> _mockLogger;
private readonly ProductsController _controller;
public ProductsControllerTests()
{
_mockProductService = new Mock<IProductService>();
_mockLogger = new Mock<ILogger<ProductsController>>();
_controller = new ProductsController(_mockProductService.Object, _mockLogger.Object);
}
[Fact]
public async Task GetProducts_ReturnsOkResult_WithProducts()
{
// Arrange
var products = new List<Product>
{
new Product { Id = 1, Name = "Test Product" }
};
_mockProductService.Setup(service => service.GetAllProductsAsync())
.ReturnsAsync(products);
// Act
var result = await _controller.GetProducts();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var returnedProducts = Assert.IsAssignableFrom<IEnumerable<Product>>(okResult.Value);
Assert.Single(returnedProducts);
}
}
Frontend Testing with Jasmine and Karma
Angular provides built-in testing tools:
Test a service:
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ProductService]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should retrieve products from the API', () => {
const mockProducts = [
{ id: 1, name: 'Product 1', price: 10 },
{ id: 2, name: 'Product 2', price: 20 }
];
service.getProducts().subscribe(products => {
expect(products.length).toBe(2);
expect(products).toEqual(mockProducts);
});
const req = httpMock.expectOne('api/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
});
});
Test a component:
describe('ProductListComponent', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let mockProductService: jasmine.SpyObj<ProductService>;
beforeEach(async () => {
mockProductService = jasmine.createSpyObj('ProductService', ['getProducts', 'deleteProduct']);
await TestBed.configureTestingModule({
declarations: [ProductListComponent],
providers: [
{ provide: ProductService, useValue: mockProductService }
]
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
});
it('should load products on init', () => {
const mockProducts = [
{ id: 1, name: 'Test Product', price: 10, isAvailable: true }
];
mockProductService.getProducts.and.returnValue(of(mockProducts));
fixture.detectChanges(); // Triggers ngOnInit
expect(component.products.length).toBe(1);
expect(component.loading).toBeFalse();
});
});
End-to-End Testing with Cypress
For comprehensive end-to-end testing:
Install Cypress:
cd ClientApp
npm install cypress --save-dev
Create a test spec:
// cypress/integration/products.spec.js
describe('Products Page', () => {
beforeEach(() => {
// Login before each test
cy.visit('/login');
cy.get('input[name=email]').type('[email protected]');
cy.get('input[name=password]').type('password123');
cy.get('button[type=submit]').click();
cy.url().should('include', '/products');
});
it('displays products list', () => {
cy.get('table tbody tr').should('have.length.gt', 0);
});
it('can navigate to product details', () => {
cy.get('table tbody tr:first-child a:contains("View")').click();
cy.url().should('include', '/products/');
cy.get('h2').should('contain', 'Product Details');
});
it('can create a new product', () => {
cy.get('a:contains("Add New Product")').click();
cy.url().should('include', '/products/new');
cy.get('input[formControlName=name]').type('New Test Product');
cy.get('input[formControlName=price]').type('29.99');
cy.get('textarea[formControlName=description]').type('This is a test product');
cy.get('button[type=submit]').click();
cy.url().should('include', '/products');
cy.get('table').should('contain', 'New Test Product');
});
});
Conclusion
Building Single Page Applications with ASP.NET Core and Angular combines the best of both worlds: a robust, scalable backend with a responsive, modern frontend. This architecture offers significant advantages for developing complex web applications that deliver excellent user experiences.
Throughout this article, we've explored the fundamental concepts of SPAs and how to implement them using ASP.NET Core and Angular. We've covered setting up the development environment, creating a backend API with ASP.NET Core, building interactive UI components with Angular, implementing authentication and authorization, and optimizing for production deployment.
The key takeaways from this guide include:
Architectural Benefits: The separation of concerns between frontend and backend allows specialized teams to work independently and promotes code reusability.
Modern User Experience: SPAs provide a more responsive and interactive experience that meets today's user expectations.
Scalability: Both ASP.NET Core and Angular are designed to scale, making this combination suitable for applications of any size.
Security: Proper implementation of authentication and authorization protects both your API and client application.
Performance Optimization: Techniques like lazy loading, server-side rendering, and API optimization ensure your application performs well under various conditions.
As you continue developing your SPA, remember that both technologies are actively evolving. Stay updated with the latest features and best practices from both the ASP.NET Core and Angular communities to ensure your application remains modern and efficient.
By following the practices outlined in this guide, you'll be well-equipped to create sophisticated, maintainable, and high-performing web applications that deliver exceptional value to your users.
Don't Miss Out!
Enjoyed this article? Subscribe to ASP Today to receive more in-depth technical content on ASP.NET development delivered straight to your inbox. Join our growing community of developers who are building the future of web applications with ASP.NET.
Have questions or want to discuss ASP.NET and Angular integration? Join our Substack Chat community where you can connect with fellow developers and get expert advice on your projects.
Subscribe Now to stay updated with the latest ASP.NET tips, tutorials, and best practices!