E-Commerce Storefront
An e-commerce storefront is the project that ties together everything Angular does well: feature routing, lazy loading, signal-based state for a cart that lives across pages, and reactive forms for a multi-step checkout. In this guide you will build a storefront with a product catalog, a persistent cart, and a validated checkout flow using modern Angular 18+ patterns — standalone components, signals, the @if/@for control flow, inject(), and a functional route guard. The result is a realistic, production-shaped app you can extend with a real backend.
Project structure
Lazy-loaded feature areas keep the initial bundle small: the catalog ships first, while checkout code only downloads when the user navigates there. Organize the app by feature rather than by type.
ng new storefront --standalone --routing --style=css
cd storefront
ng generate component features/catalog/product-list --standalone
ng generate component features/cart/cart-page --standalone
ng generate component features/checkout/checkout-page --standalone
ng generate service core/cart
ng generate service core/products
Modeling products and the cart
Start with plain TypeScript interfaces. The cart stores line items — a product plus a quantity — so totals can be derived rather than stored.
// src/app/core/models.ts
export interface Product {
id: number;
name: string;
price: number;
image: string;
}
export interface CartItem {
product: Product;
qty: number;
}
The CartService owns all cart state in a single writable signal. Totals are computed() signals, so the UI updates automatically and never drifts out of sync. An effect() persists the cart to localStorage so a refresh does not empty it.
// src/app/core/cart.service.ts
import { Injectable, computed, effect, signal } from '@angular/core';
import { CartItem, Product } from './models';
const KEY = 'storefront-cart';
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = signal<CartItem[]>(this.load());
readonly lines = this.items.asReadonly();
readonly count = computed(() => this.items().reduce((n, i) => n + i.qty, 0));
readonly total = computed(() =>
this.items().reduce((sum, i) => sum + i.product.price * i.qty, 0),
);
constructor() {
effect(() => localStorage.setItem(KEY, JSON.stringify(this.items())));
}
add(product: Product): void {
this.items.update((list) => {
const existing = list.find((i) => i.product.id === product.id);
if (existing) {
return list.map((i) =>
i.product.id === product.id ? { ...i, qty: i.qty + 1 } : i,
);
}
return [...list, { product, qty: 1 }];
});
}
setQty(id: number, qty: number): void {
this.items.update((list) =>
list
.map((i) => (i.product.id === id ? { ...i, qty } : i))
.filter((i) => i.qty > 0),
);
}
clear(): void {
this.items.set([]);
}
private load(): CartItem[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '[]');
} catch {
return [];
}
}
}
Expose state through
asReadonly()andcomputed(). Components should never mutate the underlying array directly — funneling every change through service methods keeps the cart predictable and easy to test.
Routing and lazy loading
Each feature is loaded with loadComponent, so the catalog, cart, and checkout become separate chunks. The checkout route is protected by a functional guard that redirects empty carts back to the cart page.
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { CartService } from './core/cart.service';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./features/catalog/product-list/product-list.component')
.then((m) => m.ProductListComponent),
},
{
path: 'cart',
loadComponent: () =>
import('./features/cart/cart-page/cart-page.component')
.then((m) => m.CartPageComponent),
},
{
path: 'checkout',
canActivate: [
() => {
const cart = inject(CartService);
const router = inject(Router);
return cart.count() > 0 ? true : router.createUrlTree(['/cart']);
},
],
loadComponent: () =>
import('./features/checkout/checkout-page/checkout-page.component')
.then((m) => m.CheckoutPageComponent),
},
];
The product list
The list component injects CartService and renders the catalog with @for. A track expression is required and keeps DOM reuse efficient.
// src/app/features/catalog/product-list/product-list.component.ts
import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CartService } from '../../../core/cart.service';
import { Product } from '../../../core/models';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [RouterLink],
template: `
<header>
<a routerLink="/cart">Cart ({{ cart.count() }})</a>
</header>
<div class="grid">
@for (p of products(); track p.id) {
<article>
<img [src]="p.image" [alt]="p.name" />
<h3>{{ p.name }}</h3>
<p>{{ p.price | currency }}</p>
<button (click)="cart.add(p)">Add to cart</button>
</article>
} @empty {
<p>No products available.</p>
}
</div>
`,
})
export class ProductListComponent {
readonly cart = inject(CartService);
readonly products = signal<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 129, image: '/kb.jpg' },
{ id: 2, name: 'Wireless Mouse', price: 49, image: '/mouse.jpg' },
]);
}
The checkout form
Checkout uses a typed reactive form. Validators enforce required fields and formats, and the submit button stays disabled until the form is valid. On submit you would post to an API; here we log the order and clear the cart.
// src/app/features/checkout/checkout-page/checkout-page.component.ts
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
FormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { CartService } from '../../../core/cart.service';
@Component({
selector: 'app-checkout-page',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<h2>Checkout — {{ cart.total() | currency }}</h2>
<form [formGroup]="form" (ngSubmit)="placeOrder()">
<input formControlName="name" placeholder="Full name" />
<input formControlName="email" placeholder="Email" type="email" />
<input formControlName="address" placeholder="Shipping address" />
@if (form.controls.email.touched && form.controls.email.invalid) {
<small>Enter a valid email.</small>
}
<button type="submit" [disabled]="form.invalid">Place order</button>
</form>
`,
})
export class CheckoutPageComponent {
readonly cart = inject(CartService);
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
readonly form = this.fb.nonNullable.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
address: ['', Validators.required],
});
placeOrder(): void {
if (this.form.invalid) return;
console.log('Order placed', {
customer: this.form.getRawValue(),
items: this.cart.lines(),
total: this.cart.total(),
});
this.cart.clear();
this.router.navigate(['/']);
}
}
Output:
Order placed {
customer: { name: 'Ada Lovelace', email: '[email protected]', address: '12 Analytical St' },
items: [ { product: { id: 1, name: 'Mechanical Keyboard', price: 129 }, qty: 2 } ],
total: 258
}
Best practices
- Keep cart state in a single injectable service and expose it through
computed()so totals can never disagree with the line items. - Lazy-load each feature with
loadComponentto keep the first paint fast; only checkout code downloads when the user reaches checkout. - Guard the checkout route with a functional
canActivateguard so users cannot reach it with an empty cart. - Always provide a
trackexpression in@for; for catalogs use a stable product id, never the index. - Use typed
nonNullablereactive forms with explicitValidators, and disable submit whileform.invalid. - Persist the cart with an
effect()writing tolocalStorage, and wrap the read in a try/catch to survive corrupted data.