SAP Commerce Composable Storefront — still widely known as Spartacus — is the Angular-based frontend framework for SAP Commerce Cloud. It replaces the Accelerator server-side storefronts with a decoupled, JavaScript-driven single-page application that communicates with the backend entirely through the OCC REST API. This architecture enables independent frontend deployments, CDN-friendly content delivery, and a modern development experience.
This article covers the architecture, customization model, state management, CMS integration, and production deployment patterns that every Spartacus developer needs to understand.
┌───────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Spartacus Application (Angular SPA) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐│ │
│ │ │ NgRx │ │ CMS │ │ Feature Modules ││ │
│ │ │ Store │ │ Mapping │ │ (Cart, Checkout, ││ │
│ │ │ │ │ Engine │ │ PDP, PLP, etc.) ││ │
│ │ └──────────┘ └──────────┘ └──────────────────┘│ │
│ │ ┌──────────────────────────────────────────────┐│ │
│ │ │ OCC Adapter Layer (HTTP → Backend) ││ │
│ │ └──────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
│ REST API calls (OCC v2)
▼
┌───────────────────────────────────────────────────────┐
│ SAP Commerce Cloud Backend │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ OCC API │ │ CMS │ │ Commerce Services │ │
│ │ Layer │ │ Engine │ │ (Cart, Pricing, │ │
│ │ │ │ │ │ Checkout, Search) │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
└───────────────────────────────────────────────────────┘
Headless: The frontend knows nothing about the backend’s Java code or database. All communication happens via HTTP/JSON through the OCC API.
CMS-Driven Layout: Page layout, component placement, and content are managed in the SAP Commerce CMS (SmartEdit). The frontend receives a page structure from the CMS API and dynamically renders Angular components.
Feature Libraries: Spartacus is modular. Features like cart, checkout, product display, and user management are packaged as separate Angular libraries (@spartacus/cart, @spartacus/checkout, etc.).
NgRx State Management: Global state (user session, cart, product data) is managed through NgRx stores with actions, effects, and reducers.
Configurability Over Code: Many behaviors can be changed through configuration (TypeScript objects) rather than writing new code.
# Create a new Angular workspace
ng new mystore --style=scss --routing=true
cd mystore
# Add Spartacus schematics
ng add @spartacus/schematics \
--baseUrl=https://my-commerce-backend.com \
--baseSite=electronics-spa \
--occPrefix=/occ/v2 \
--features=Checkout,Cart,User,Product,Navigation,SmartEdit,ASM
This scaffolds:
mystore/
├── src/
│ ├── app/
│ │ ├── app.module.ts
│ │ ├── app.component.ts
│ │ └── spartacus/
│ │ ├── spartacus-features.module.ts
│ │ ├── spartacus-configuration.module.ts
│ │ └── spartacus.module.ts
│ ├── styles.scss
│ └── index.html
├── angular.json
├── package.json
└── tsconfig.json
// spartacus-configuration.module.ts
@NgModule({
providers: [
provideConfig(<OccConfig>{
backend: {
occ: {
baseUrl: 'https://my-commerce-backend.com',
prefix: '/occ/v2/',
},
},
}),
provideConfig(<SiteContextConfig>{
context: {
baseSite: ['electronics-spa'],
language: ['en', 'de'],
currency: ['USD', 'EUR'],
},
}),
provideConfig(<RoutingConfig>{
routing: {
routes: {
product: {
paths: ['product/:productCode/:name'],
},
},
},
}),
],
})
export class SpartacusConfigurationModule {}
This is the most important concept in Spartacus. Pages are not hardcoded Angular routes with static templates. Instead:
/product/123/cameraThe OCC CMS API returns:
{
"uid": "productDetailPage",
"template": "ProductDetailsPageTemplate",
"contentSlots": {
"contentSlot": [
{
"slotId": "ProductSummarySlot",
"position": "Summary",
"components": {
"component": [
{ "uid": "ProductImagesComponent", "typeCode": "CMSFlexComponent" },
{ "uid": "ProductSummaryComponent", "typeCode": "CMSFlexComponent" },
{ "uid": "ProductAddToCartComponent", "typeCode": "CMSFlexComponent" }
]
}
},
{
"slotId": "ProductTabsSlot",
"position": "Tabs",
"components": {
"component": [
{ "uid": "ProductDetailsTabComponent", "typeCode": "CMSTabParagraphContainer" },
{ "uid": "ProductReviewsTabComponent", "typeCode": "CMSFlexComponent" }
]
}
}
]
}
}
Spartacus maps CMS component types to Angular components:
provideConfig(<CmsConfig>{
cmsComponents: {
ProductImagesComponent: {
component: ProductImagesComponent,
},
ProductSummaryComponent: {
component: ProductSummaryComponent,
},
ProductAddToCartComponent: {
component: AddToCartComponent,
},
// Custom component mapping
LoyaltyPointsDisplayComponent: {
component: LoyaltyPointsComponent,
providers: [
{
provide: LoyaltyService,
useClass: LoyaltyService,
},
],
},
},
})
In templates, the <cx-page-slot> directive renders all components assigned to a slot:
<!-- Page layout template -->
<div class="product-detail">
<div class="summary-section">
<cx-page-slot position="Summary"></cx-page-slot>
</div>
<div class="tabs-section">
<cx-page-slot position="Tabs"></cx-page-slot>
</div>
<div class="recommendations">
<cx-page-slot position="CrossSelling"></cx-page-slot>
</div>
</div>
The content of each slot is entirely determined by the CMS configuration in the backend. Business users can add, remove, or reorder components through SmartEdit without any frontend deployment.
To replace an out-of-the-box component with your custom implementation:
@Component({
selector: 'app-custom-add-to-cart',
template: `
<div class="custom-add-to-cart">
<div class="quantity-selector">
<button (click)="decrement()">-</button>
<input type="number" [value]="quantity" (change)="onQuantityChange($event)"/>
<button (click)="increment()">+</button>
</div>
<button
class="btn btn-primary"
(click)="addToCart()"
[disabled]="!product?.stock?.stockLevelStatus || product.stock.stockLevelStatus === 'outOfStock'">
<span *ngIf="product?.stock?.stockLevelStatus === 'outOfStock'">Out of Stock</span>
<span *ngIf="product?.stock?.stockLevelStatus !== 'outOfStock'">
Add to Cart — {{ product?.price?.formattedValue }}
</span>
</button>
<app-loyalty-points-preview
*ngIf="loyaltyPoints > 0"
[points]="loyaltyPoints">
</app-loyalty-points-preview>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomAddToCartComponent extends AddToCartComponent {
loyaltyPoints = 0;
constructor(
// Inject all parent dependencies
protected currentProductService: CurrentProductService,
protected activeCartFacade: ActiveCartFacade,
private loyaltyService: LoyaltyService
) {
super(currentProductService, activeCartFacade);
}
ngOnInit() {
super.ngOnInit();
this.currentProductService.getProduct().subscribe(product => {
if (product?.price?.value) {
this.loyaltyPoints = Math.floor(product.price.value);
}
});
}
}
Register the override:
provideConfig(<CmsConfig>{
cmsComponents: {
ProductAddToCartComponent: {
component: CustomAddToCartComponent,
},
},
})
For components that don’t exist in the standard library:
1. Define the CMS component in SAP Commerce (ImpEx):
INSERT_UPDATE CMSFlexComponent;uid[unique=true];name;flexType;$catalogVersion
;LoyaltyDashboardComponent;Loyalty Dashboard;LoyaltyDashboardComponent;
2. Create the Angular component:
@Component({
selector: 'app-loyalty-dashboard',
template: `
<div class="loyalty-dashboard" *ngIf="account$ | async as account">
<div class="tier-badge" [ngClass]="account.tier | lowercase">
{{ account.tier }}
</div>
<div class="points-display">
<span class="points-value">{{ account.points | number }}</span>
<span class="points-label">Points</span>
</div>
<div class="progress-bar">
<div class="progress" [style.width.%]="getProgress(account)"></div>
<span class="next-tier">{{ account.pointsToNextTier | number }} points to next tier</span>
</div>
<div class="recent-transactions">
<h3>Recent Activity</h3>
<div *ngFor="let tx of account.recentTransactions" class="transaction">
<span class="tx-date">{{ tx.date | date }}</span>
<span class="tx-desc">{{ tx.description }}</span>
<span class="tx-points" [class.positive]="tx.points > 0">
{{ tx.points > 0 ? '+' : '' }}{{ tx.points }}
</span>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoyaltyDashboardComponent implements OnInit {
account$: Observable<LoyaltyAccount>;
constructor(private loyaltyService: LoyaltyService) {}
ngOnInit() {
this.account$ = this.loyaltyService.getAccount();
}
getProgress(account: LoyaltyAccount): number {
const tierThresholds = { BRONZE: 1000, SILVER: 5000, GOLD: 20000, PLATINUM: 100000 };
const nextThreshold = tierThresholds[account.tier] || 100000;
return Math.min(100, (account.points / nextThreshold) * 100);
}
}
3. Register the CMS mapping:
provideConfig(<CmsConfig>{
cmsComponents: {
LoyaltyDashboardComponent: {
component: LoyaltyDashboardComponent,
},
},
})
Now business users can place this component on any page via SmartEdit.
Spartacus uses NgRx for state management. Understanding the pattern is essential for custom features.
Actions:
// loyalty.actions.ts
export const LOAD_LOYALTY_ACCOUNT = '[Loyalty] Load Account';
export const LOAD_LOYALTY_ACCOUNT_SUCCESS = '[Loyalty] Load Account Success';
export const LOAD_LOYALTY_ACCOUNT_FAIL = '[Loyalty] Load Account Fail';
export class LoadLoyaltyAccount implements Action {
readonly type = LOAD_LOYALTY_ACCOUNT;
}
export class LoadLoyaltyAccountSuccess implements Action {
readonly type = LOAD_LOYALTY_ACCOUNT_SUCCESS;
constructor(public payload: LoyaltyAccount) {}
}
export class LoadLoyaltyAccountFail implements Action {
readonly type = LOAD_LOYALTY_ACCOUNT_FAIL;
constructor(public payload: any) {}
}
Reducer:
// loyalty.reducer.ts
export interface LoyaltyState {
account: LoyaltyAccount | null;
loading: boolean;
error: any;
}
const initialState: LoyaltyState = {
account: null,
loading: false,
error: null,
};
export function loyaltyReducer(state = initialState, action: LoyaltyActions): LoyaltyState {
switch (action.type) {
case LOAD_LOYALTY_ACCOUNT:
return { ...state, loading: true, error: null };
case LOAD_LOYALTY_ACCOUNT_SUCCESS:
return { ...state, account: action.payload, loading: false };
case LOAD_LOYALTY_ACCOUNT_FAIL:
return { ...state, error: action.payload, loading: false };
default:
return state;
}
}
Effects:
// loyalty.effects.ts
@Injectable()
export class LoyaltyEffects {
loadAccount$ = createEffect(() =>
this.actions$.pipe(
ofType(LOAD_LOYALTY_ACCOUNT),
switchMap(() =>
this.loyaltyConnector.getAccount().pipe(
map(account => new LoadLoyaltyAccountSuccess(account)),
catchError(error => of(new LoadLoyaltyAccountFail(error)))
)
)
)
);
constructor(
private actions$: Actions,
private loyaltyConnector: LoyaltyConnector
) {}
}
Connector (OCC Adapter):
// loyalty.connector.ts
@Injectable({ providedIn: 'root' })
export class LoyaltyConnector {
constructor(private http: HttpClient, private occEndpoints: OccEndpointsService) {}
getAccount(): Observable<LoyaltyAccount> {
const url = this.occEndpoints.buildUrl('loyaltyAccount');
return this.http.get<LoyaltyAccount>(url);
}
redeemPoints(points: number): Observable<LoyaltyAccount> {
const url = this.occEndpoints.buildUrl('loyaltyRedeem');
return this.http.post<LoyaltyAccount>(url, { points });
}
}
Endpoint Configuration:
provideConfig(<OccConfig>{
backend: {
occ: {
endpoints: {
loyaltyAccount: 'users/${userId}/loyalty/account',
loyaltyRedeem: 'users/${userId}/loyalty/redeem',
loyaltyTransactions: 'users/${userId}/loyalty/transactions?currentPage=${currentPage}&pageSize=${pageSize}',
},
},
},
})
Spartacus layout is configured through TypeScript objects, not CSS grids alone.
provideConfig(<LayoutConfig>{
layoutSlots: {
ProductDetailsPageTemplate: {
slots: ['Summary', 'UpSelling', 'Tabs', 'CrossSelling'],
lg: {
slots: [
{ slot: 'Summary', flex: '60' },
{ slot: 'UpSelling', flex: '40' },
'Tabs',
'CrossSelling',
],
},
},
LandingPage2Template: {
slots: [
'Section1',
'Section2A',
'Section2B',
'Section3',
'Section4',
'Section5',
],
},
// Custom page template
LoyaltyPageTemplate: {
slots: ['LoyaltyHeader', 'LoyaltyDashboard', 'LoyaltyHistory'],
},
},
})
provideConfig(<LayoutConfig>{
layoutSlots: {
header: {
lg: {
slots: [
'PreHeader',
'SiteContext',
'SiteLinks',
'SiteLogo',
'SearchBox',
'SiteLogin',
'MiniCart',
'NavigationBar',
],
},
slots: ['PreHeader', 'SiteLogo', 'SearchBox', 'MiniCart', 'hamburger'],
},
footer: {
slots: ['Footer'],
},
},
})
Spartacus uses SCSS with a BEM-like naming convention. Override styles through the component style hierarchy.
// styles.scss
$primary: #0a6ed1;
$secondary: #354a5f;
$font-family: 'Open Sans', sans-serif;
// Override Spartacus variables
$cx-g-font-family: $font-family;
$cx-g-color-primary: $primary;
$cx-g-color-secondary: $secondary;
// Import Spartacus styles
@import '@spartacus/styles';
@import '@spartacus/styles/scss/theme';
// Custom product card styling
cx-product-list-item {
.cx-product-image {
border-radius: 8px;
overflow: hidden;
}
.cx-product-name {
font-weight: 600;
font-size: 1.1rem;
}
.cx-product-price {
color: $primary;
font-size: 1.2rem;
}
}
// Loyalty dashboard custom styles
app-loyalty-dashboard {
.loyalty-dashboard {
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
}
.tier-badge {
&.bronze { background: #cd7f32; }
&.silver { background: #c0c0c0; color: #333; }
&.gold { background: #ffd700; color: #333; }
&.platinum { background: #e5e4e2; color: #333; }
display: inline-block;
padding: 0.25rem 1rem;
border-radius: 20px;
font-weight: bold;
text-transform: uppercase;
}
}
SSR is critical for SEO and initial page load performance. Spartacus supports SSR via Angular Universal.
ng add @spartacus/schematics --ssr
This adds:
// server.ts
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { AppServerModule } from './src/main.server';
const app = express();
const PORT = process.env['PORT'] || 4000;
app.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
app.set('view engine', 'html');
app.set('views', join(DIST_FOLDER, 'browser'));
// Serve static files
app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));
// All routes use SSR
app.get('*', (req, res) => {
res.render('index', { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
});
app.listen(PORT, () => console.log(`SSR server listening on port ${PORT}`));
Spartacus uses Angular’s TransferState to avoid duplicate API calls. Data fetched on the server is serialized into the HTML and reused by the browser:
Server renders page → embeds API responses as JSON in HTML →
Browser boots Angular → reads transferred state → skips redundant API calls
provideConfig({
ssr: {
timeout: 3000, // Fallback to client-side render after 3s
},
})
Spartacus lazy-loads feature modules automatically:
// spartacus-features.module.ts
@NgModule({
imports: [
// These modules are lazy-loaded when the user navigates to relevant pages
CartBaseFeatureModule, // Loaded when accessing cart
CheckoutFeatureModule, // Loaded at checkout
UserFeatureModule, // Loaded for account pages
ProductFeatureModule, // Loaded for PDP/PLP
],
})
export class SpartacusFeaturesModule {}
// Register a lazy-loaded custom module
provideConfig({
featureModules: {
loyalty: {
module: () => import('./loyalty/loyalty.module').then(m => m.LoyaltyModule),
cmsComponents: ['LoyaltyDashboardComponent', 'LoyaltyPointsDisplayComponent'],
},
},
})
The loyalty module is only downloaded when a page contains one of the listed CMS components.
# Analyze bundle sizes
ng build --stats-json
npx webpack-bundle-analyzer dist/mystore/browser/stats.json
Target main bundle size under 300KB gzipped for good initial load performance.
// angular.json (production build)
{
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
}
],
"outputHashing": "all",
"sourceMap": false,
"optimization": true,
"buildOptimizer": true,
"aot": true
}
}
}
In the CCv2 manifest:
{
"storefrontAddons": [],
"jsStorefronts": [
{
"name": "mystore",
"storefront": "mystore",
"contextRoot": "",
"nodeVersion": "18"
}
]
}
// environment.prod.ts
export const environment = {
production: true,
occBaseUrl: '', // Empty string — relative URLs in CCv2 (same origin)
};
// Use in config
provideConfig(<OccConfig>{
backend: {
occ: {
baseUrl: environment.occBaseUrl,
},
},
})
On CCv2, the JS storefront is served by the same domain as the OCC API, so no CORS configuration is needed.
Use CMS mapping for component placement — don’t hardcode component positions in templates. Let the CMS drive layout.
Prefer configuration over code — routes, endpoints, feature flags, and layout can all be changed via provideConfig() without modifying component code.
Follow the adapter/connector pattern — isolate OCC API calls in connectors. Components should never call HttpClient directly.
Use OnPush change detection — every custom component should use ChangeDetectionStrategy.OnPush for performance.
Lazy load everything possible — custom feature modules should be lazy-loaded via the featureModules configuration.
window, document, localStorage) break SSR. Use Angular’s platform checks:
import { isPlatformBrowser } from '@angular/common';
if (isPlatformBrowser(this.platformId)) {
window.scrollTo(0, 0);
}
Don’t fight the framework — Spartacus has established patterns for customization. Replacing CMS component mappings is preferred over forking library code.
SAP Commerce Composable Storefront (Spartacus) brings modern frontend architecture to SAP Commerce. The key concepts:
The combination of headless architecture and CMS-driven rendering gives teams the flexibility to evolve the frontend independently from the backend — while giving business users the power to manage content without developer involvement.