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.
Priya Ramanathan
SAP Commerce Storefront & Frontend Expert
Composable Storefront and Spartacus, SSR, OCC optimization, and Angular architecture.
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 and OCC Contract Hardening 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:
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.
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.
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,
})),
);
}
<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.
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:
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.- 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 requires an explicit DestroyRef outside an injection context; pass it rather than relying on accidental lifetime.
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:
<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.
// 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.
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 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.
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 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.
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. |
The BIHR memory-leak material reviewed for this guide contains useful project-specific ideas such as lifecycle tracking, bounded caches, and concurrency controls. Those are not SAP framework APIs or universal defaults. 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.
# 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.