# Composable Storefront Engineering: Production Practices for Angular, CMS, and SSR

> Production practices for Composable Storefront code: CMS boundaries, facades, lifecycle ownership, SSR-safe APIs, rendering policy, and release gates.

Source: https://www.commercecloudintegrations.com/guides/composable-storefront-production-engineering
Publisher: Commerce Cloud Integrations
Updated: 2026-07-13
Topics: composable, spartacus, angular, cms, ssr, occ, testing

This guide is for teams already shipping SAP Commerce Cloud, composable storefront (formerly Spartacus) and needing a codebase that survives real CMS change, OCC evolution, SSR, and upgrades. It assumes a Commerce 2211-era storefront unless a section says otherwise. Confirm the supported Angular and Composable Storefront version pair before copying an API: the framework upgrade path is not a license to mix examples from unrelated major versions.

It deliberately does **not** re-explain CMS page composition, basic `CmsConfig`, or OCC endpoint ownership. Start with [How Composable Storefront Actually Works](/guides/composable-storefront-getting-started) and [OCC Contract Hardening](/guides/composable-storefront-occ-contracts) if those are not already team knowledge. This is the engineering contract that keeps those concepts safe in production.

## Define the extension boundary before writing code

Composable Storefront is designed to be extended through configuration, dependency injection, outlets, public facades, and feature composition. A direct edit under `node_modules/@spartacus` is not an extension; it is an untracked fork that will make the next library upgrade a manual merge.

Choose the narrowest supported seam that satisfies the requirement.

| Need                               | Preferred seam                                                       | Avoid                                            | Why it matters at upgrade time                                                             |
| ---------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| Render a custom CMS type           | `CmsConfig.cmsComponents` mapping                                    | Forking a standard CMS renderer                  | The mapping is content-to-code configuration, so SAP library internals remain replaceable. |
| Add a small element to standard UI | Outlet or component composition                                      | Copying an entire feature component              | Keeps upstream accessibility, fixes, and selectors intact.                                 |
| Change data orchestration          | Application facade or a decorated adapter                            | Calling NgRx store actions from a leaf component | Preserves a stable frontend boundary when state internals change.                          |
| Add business data                  | A narrowly scoped OCC resource and typed model                       | Extending `FULL` everywhere                      | Makes field cost, cacheability, and ownership visible.                                     |
| Change a platform default          | `provideDefaultConfig` or an explicitly documented provider override | Monkey-patching private symbols                  | Keeps the customization searchable and testable.                                           |

Put project code into bounded areas. The precise folder names are less important than preventing UI, data transport, and CMS wiring from collapsing into one component:

```text
src/app/features/merchandising/
├── cms/                 # CMS type -> Angular component mapping
├── components/          # Presentational, OnPush components
├── facade/              # Stable UI-facing API
├── data-access/         # OCC adapter / connector / DTO mapping
├── model/               # Project-owned TypeScript types
└── merchandising.module.ts
```

The boundary is reviewable: a CMS component can depend on its feature facade; the facade can depend on a connector or existing Composable Storefront facade; only data-access code knows a custom OCC URL and response DTO. A template does not parse raw OCC JSON.

## Map CMS components without making them mini applications

CMS components are created from data configured by business users. Treat their data as untrusted runtime input: nullable, stale during synchronization, and potentially a type you did not map in an environment.

The following is an NgModule-style configuration used by many Commerce 2211 storefronts. For a standalone application, register the same provider through the application bootstrap/provider configuration appropriate to its supported release.

```typescript
import { ChangeDetectionStrategy, Component, NgModule } from '@angular/core';
import { CmsConfig, provideDefaultConfig } from '@spartacus/core';

@Component({
  selector: 'cci-merchandising-banner',
  templateUrl: './merchandising-banner.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MerchandisingBannerComponent {}

@NgModule({
  declarations: [MerchandisingBannerComponent],
  providers: [
    provideDefaultConfig(<CmsConfig>{
      cmsComponents: {
        CciMerchandisingBannerComponent: {
          component: MerchandisingBannerComponent,
        },
      },
    }),
  ],
})
export class MerchandisingModule {}
```

Keep the CMS renderer thin. It should validate and project CMS data, ask a feature facade for behavior, and render. It should not create subscriptions in the constructor, issue anonymous `HttpClient` calls, reach into the global store, or mutate the DOM directly.

```typescript
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CmsComponentData } from '@spartacus/storefront';
import { Observable, map } from 'rxjs';

interface MerchandisingBannerCmsData {
  uid?: string;
  headline?: string;
  copy?: string;
  ctaLabel?: string;
  urlLink?: string;
}

interface BannerViewModel {
  id: string;
  headline: string;
  copy?: string;
  cta?: { label: string; url: string };
}

@Component({
  selector: 'cci-merchandising-banner',
  templateUrl: './merchandising-banner.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MerchandisingBannerComponent {
  private readonly component = inject(CmsComponentData<MerchandisingBannerCmsData>);

  readonly vm$: Observable<BannerViewModel> = this.component.data$.pipe(
    map((data) => ({
      id: data.uid ?? 'unidentified-banner',
      headline: data.headline?.trim() || 'Promotion',
      copy: data.copy?.trim() || undefined,
      cta:
        data.ctaLabel?.trim() && data.urlLink?.trim()
          ? { label: data.ctaLabel.trim(), url: data.urlLink.trim() }
          : undefined,
    })),
  );
}
```

```html
<section *ngIf="vm$ | async as vm" class="cci-banner" [attr.data-cms-id]="vm.id">
  <h2>{{ vm.headline }}</h2>
  <p *ngIf="vm.copy">{{ vm.copy }}</p>
  <a *ngIf="vm.cta as cta" [routerLink]="cta.url">{{ cta.label }}</a>
</section>
```

`CmsComponentData` import locations vary between storefront major versions. Resolve it from the public API of the version actually installed; do not turn a compilation error into an `any` cast. The important design point is stable: consume `data$`, normalize it once, and render with `AsyncPipe`.

## Keep state behind a facade, not in a template

A component coupled to `Store`, a connector, and three services cannot be reused, server-tested, or upgraded independently. Its template also silently becomes the policy engine. Provide a small facade with semantic operations and an observable view model instead.

```typescript
import { Injectable } from '@angular/core';
import { combineLatest, Observable, map, shareReplay } from 'rxjs';
import { Product } from '@spartacus/core';
import { ActiveCartFacade } from '@spartacus/cart/base/root';
import { CurrentProductService } from '@spartacus/storefront';

export interface ProductPurchaseState {
  code: string;
  name: string;
  canAddToCart: boolean;
  cartEntryCount: number;
}

@Injectable({ providedIn: 'root' })
export class ProductPurchaseFacade {
  constructor(
    private readonly currentProduct: CurrentProductService,
    private readonly activeCart: ActiveCartFacade,
  ) {}

  get state$(): Observable<ProductPurchaseState | null> {
    return combineLatest([this.currentProduct.getProduct(), this.activeCart.getEntries()]).pipe(
      map(([product, entries]) => this.toState(product, entries.length)),
      shareReplay({ bufferSize: 1, refCount: true }),
    );
  }

  private toState(
    product: Product | undefined,
    cartEntryCount: number,
  ): ProductPurchaseState | null {
    if (!product?.code || !product.name) {
      return null;
    }

    return {
      code: product.code,
      name: product.name,
      canAddToCart: product.purchasable === true,
      cartEntryCount,
    };
  }
}
```

This is not a rule that every project needs a new facade for every selector. Reuse the Composable Storefront facades where their public API already expresses the use case. Add a project facade when it composes several sources, gives a domain name to custom behavior, or prevents UI code from taking a dependency on a connector/adapter/store implementation.

Two details prevent subtle production defects:

1. `shareReplay({ bufferSize: 1, refCount: true })` is appropriate only when the source may safely re-subscribe and the cached value is not a cross-request secret. Never use an unbounded replay buffer in an SSR process.
2. Do not cache a customer, cart, or authorization-specific view model in a static/global singleton. SSR uses server process memory; request and user boundaries remain security boundaries even when Node stays alive.

## Make lifetime ownership explicit

Angular and RxJS make it easy to start background work. SSR makes leaks expensive because an app instance is repeatedly created and destroyed in Node. Every subscription, timer, listener, and retained cache needs an owner and an end condition.

For Angular versions that support it, `takeUntilDestroyed` binds a stream to `DestroyRef`. The [Angular API](https://angular.dev/api/core/rxjs-interop/takeUntilDestroyed) requires an explicit `DestroyRef` outside an injection context; pass it rather than relying on accidental lifetime.

```typescript
import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EventService } from '@spartacus/core';
import { CartAddEntrySuccessEvent } from '@spartacus/cart/base/root';
import { filter } from 'rxjs';

@Component({
  selector: 'cci-cart-announcement',
  template: '<p *ngIf="message">{{ message }}</p>',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CartAnnouncementComponent {
  private readonly destroyRef = inject(DestroyRef);
  private readonly eventService = inject(EventService);

  message = '';

  constructor() {
    this.eventService
      .get(CartAddEntrySuccessEvent)
      .pipe(
        filter((event) => Boolean(event.productCode)),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((event) => {
        this.message = `${event.productCode} added to cart`;
      });
  }
}
```

The exact feature-package export of `CartAddEntrySuccessEvent` can differ across storefront major versions. Resolve it through the public barrel of the release you installed rather than bypassing TypeScript. The rule is more durable than the symbol name: an `EventService` subscription must end when its owner ends. For a template-only stream, do not subscribe at all:

```html
<ng-container *ngIf="purchaseFacade.state$ | async as state">
  <button [disabled]="!state.canAddToCart">Add {{ state.name }}</button>
</ng-container>
```

| Resource                     | Correct owner                          | Safe pattern                                                    | SSR failure when forgotten                               |
| ---------------------------- | -------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------- |
| Component stream             | Component                              | `AsyncPipe` or `takeUntilDestroyed(componentDestroyRef)`        | Retained component closure after request destruction.    |
| EventService listener        | Component or feature service           | Lifecycle-bound subscription                                    | One handler per past SSR request.                        |
| DOM listener                 | Component                              | `Renderer2.listen()` and invoke its returned cleanup function   | Browser-only API throws or listener survives navigation. |
| `setTimeout` / `setInterval` | Component/service that starts it       | Clear it in `ngOnDestroy` or avoid it with a bounded observable | Render never stabilizes, SSR hangs.                      |
| Cache                        | Explicit service with key/TTL/max size | Scope by public content and measure eviction                    | Cross-user data retention or an expanding Node heap.     |

Avoid nested `subscribe()` calls. Compose work with `switchMap`, `concatMap`, or `exhaustMap` so cancellation and errors have one owner.

```typescript
// Bad: the inner stream is detached from the outer lifecycle and error path.
this.productCode$.subscribe((code) => {
  this.recommendationService.load(code).subscribe();
});

// Good: route changes cancel an obsolete request; one subscription owns the chain.
readonly recommendations$ = this.productCode$.pipe(
  filter((code): code is string => Boolean(code)),
  distinctUntilChanged(),
  switchMap((code) => this.recommendationService.load(code)),
);
```

Use imports for `distinctUntilChanged` and `switchMap` from `rxjs`; the snippet focuses on the lifetime boundary, not a particular feature API.

## Treat browser APIs as optional capabilities

At least three execution contexts matter: SSR in Node, browser hydration, and browser navigation after hydration. Code that reads `window`, `document`, `localStorage`, `matchMedia`, or `navigator` at module load or constructor time can make the server render throw before it produces HTML.

Use Composable Storefront's `WindowRef` and `Renderer2`, and make browser-only code opt in.

```typescript
import { Component, ElementRef, Renderer2, inject } from '@angular/core';
import { WindowRef } from '@spartacus/core';

@Component({
  selector: 'cci-sticky-purchase-panel',
  template: '<aside class="purchase-panel"><ng-content /></aside>',
})
export class StickyPurchasePanelComponent {
  private readonly windowRef = inject(WindowRef);
  private readonly renderer = inject(Renderer2);
  private readonly host = inject(ElementRef<HTMLElement>);
  private removeScrollListener?: () => void;

  ngOnInit(): void {
    if (!this.windowRef.isBrowser()) {
      return;
    }

    const nativeWindow = this.windowRef.nativeWindow;
    if (!nativeWindow) {
      return;
    }

    this.removeScrollListener = this.renderer.listen(nativeWindow, 'scroll', () => {
      const isCompact = nativeWindow.scrollY > 320;
      this.renderer.setAttribute(this.host.nativeElement, 'data-compact', String(isCompact));
    });
  }

  ngOnDestroy(): void {
    this.removeScrollListener?.();
  }
}
```

Never solve an SSR-only error with `const window: any = globalThis.window ?? {}`. That conceals a platform mismatch and creates a fake environment whose behavior production code cannot trust. SAP's [SSR coding guidelines](https://help.sap.com/docs/SAP_COMMERCE_COMPOSABLE_STOREFRONT/eaef8c61b6d9477daf75bff9ac1b7eb4/05c7f6d6c2de477f86467f47980e90e9.html?version=6.8) explicitly call out browser-global access, direct DOM manipulation, unbounded timeouts, and request-origin handling as SSR concerns.

## Make SSR a deliberate rendering policy

SSR is not a switch that must be enabled for every URL. It is a resource-managed rendering tier. The optimized engine queues work, applies a concurrency limit, and can fall back to CSR. A custom strategy must preserve the platform's defaults—especially the SmartEdit exclusion—rather than replacing them with a simplistic user-agent check.

The following 2211-style `server.ts` pattern uses SAP's public SSR setup API. Verify the package path against your installed release before merging.

```typescript
import { Request } from 'express';
import {
  defaultRenderingStrategyResolver,
  defaultRenderingStrategyResolverOptions,
  NgExpressEngineDecorator,
  ngExpressEngine as engine,
  RenderingStrategy,
  SsrOptimizationOptions,
} from '@spartacus/setup/ssr';

const isCrawler = (request: Request): boolean =>
  /bot|crawl|slurp|spider|mediapartners/i.test(request.get('user-agent') ?? '');

const ssrOptions: SsrOptimizationOptions = {
  // Preserve exclusions such as checkout, my-account, ASM, and SmartEdit behavior.
  renderingStrategyResolver: (request: Request) =>
    isCrawler(request)
      ? RenderingStrategy.ALWAYS_SSR
      : defaultRenderingStrategyResolver(defaultRenderingStrategyResolverOptions)(request),
};

const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions);

server.engine('html', ngExpressEngine({ bootstrap: AppServerModule }));
```

The [SAP SSR optimization reference](https://help.sap.com/docs/SAP_COMMERCE_COMPOSABLE_STOREFRONT/eaef8c61b6d9477daf75bff9ac1b7eb4/c48860c28fbf443d906c682a2aed23b2.html?locale=en-US) documents the semantics of `ALWAYS_CSR`, `DEFAULT`, and `ALWAYS_SSR`, including the forced timeout safety valve. It also documents default exclusions and recommends retaining `defaultRenderingStrategyResolver` as the fallback. Do not hard-code an arbitrary `User-Agent` regex into a release without testing real crawlers, preview routes, authenticated routes, and cache keys.

### Avoid duplicate SSR fetches without leaking state

The server app and browser app can both fetch the same public resource during hydration. Transfer State is the correct optimization when the data is safe to serialize to the page and genuinely stable for the request. It is not a replacement for authorization, nor a cache for user/cart/checkout state.

```typescript
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { isPlatformServer } from '@angular/common';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { Observable, of, tap } from 'rxjs';

interface StoreNotice {
  id: string;
  text: string;
}

@Injectable({ providedIn: 'root' })
export class StoreNoticeService {
  private readonly http = inject(HttpClient);
  private readonly transferState = inject(TransferState);
  private readonly platformId = inject(PLATFORM_ID);

  load(baseSite: string): Observable<StoreNotice[]> {
    const key = makeStateKey<StoreNotice[]>(`store-notices:${baseSite}`);
    const fromServer = this.transferState.get<StoreNotice[] | null>(key, null);

    if (fromServer) {
      this.transferState.remove(key);
      return of(fromServer);
    }

    return this.http.get<StoreNotice[]>(`/occ/v2/${encodeURIComponent(baseSite)}/notices`).pipe(
      tap((notices) => {
        if (isPlatformServer(this.platformId)) {
          this.transferState.set(key, notices);
        }
      }),
    );
  }
}
```

The exact server/browser guards depend on the Angular transfer-state integration in the installed release. The invariant does not: the key must contain every public variant that affects the payload (for example base site, language, currency, and catalog), and it must never serialize PII, tokens, cart contents, or personalization intended for one user.

## Put performance contracts in code review

Performance is not a post-release profiling task when a CMS page can add components and each component can trigger OCC calls. Review a page as a bounded data-flow graph.

| Review question                                     | Evidence to demand                                                   | Typical fix                                                                              |
| --------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Does a CMS component load data?                     | Network trace for first SSR request and post-hydration browser state | Batch CMS data, expose a route-level facade, or add a narrow OCC endpoint.               |
| Does it request the same data as another component? | URL + `fields` comparison, including cache headers                   | Share a facade stream or align field-set ownership.                                      |
| Can route changes cancel obsolete work?             | Test with a slow request and rapid PLP/PDP navigation                | Use `switchMap`; do not retain a stale subscription.                                     |
| Does its data vary per user?                        | Anonymous/authenticated and multi-site response comparison           | Keep it out of public SSR/CDN/transfer-state caches.                                     |
| Does it run after each render?                      | Flame chart or server CPU sample                                     | Remove getters with side effects, unnecessary timers, and repeated mapping in templates. |

Project-specific memory-leak tooling — lifecycle tracking, bounded caches, and concurrency controls — is useful, but it is _not_ an SAP framework API or a universal default. Adopt such instrumentation only after measuring a concrete leak and owning its lifecycle, retention policy, and operational cost.

## A release gate for custom storefront code

Use this as a pull-request gate, not a retrospective checklist.

| Gate       | Must be true before merge                                                                                                          |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Version    | Angular, Composable Storefront, and SSR package imports match the target release; no private/deprecated symbol is introduced.      |
| CMS        | Custom CMS type is mapped, handles absent/partial data, and renders in SmartEdit/preview as well as the storefront.                |
| Data       | UI depends on a public facade or an explicitly owned project facade; custom OCC response has a contract test and bounded `fields`. |
| Lifecycle  | Every explicit subscription, DOM listener, timer, observer, and cache has an owner and cleanup/TTL.                                |
| SSR        | No browser global at import/constructor time; SSR HTML, hydration, and browser navigation are tested separately.                   |
| Security   | No user-specific payload enters Transfer State, a shared in-memory cache, or a CDN cache without a proven key and policy.          |
| Operations | The change has an observable symptom: log, metric, trace, or request that can prove it works in CCv2.                              |

Finally, test the three execution modes separately. A passing browser unit test is not SSR proof; a `curl` that returns HTML is not hydration proof; and a successful SmartEdit preview is not proof of the anonymous production route.

```bash
# Replace values with an authorized non-production deployment.
curl --silent --show-error --fail \
  -H 'User-Agent: Mozilla/5.0' \
  'https://storefront.example.test/en/USD/p/12345' \
  | grep -E '<app-root|data-cms-id'

# In browser DevTools: verify the initial document contains rendered content,
# then verify hydration has not made the same public OCC request twice.
```

If the behavior differs among those modes, treat it as a defect in the extension boundary or rendering policy—not as a reason to disable SSR globally.
