Skip to content
Angular projects 5 min read

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() and computed(). 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 loadComponent to keep the first paint fast; only checkout code downloads when the user reaches checkout.
  • Guard the checkout route with a functional canActivate guard so users cannot reach it with an empty cart.
  • Always provide a track expression in @for; for catalogs use a stable product id, never the index.
  • Use typed nonNullable reactive forms with explicit Validators, and disable submit while form.invalid.
  • Persist the cart with an effect() writing to localStorage, and wrap the read in a try/catch to survive corrupted data.
Last updated June 14, 2026
Was this helpful?