Analytics Architecture on Salesforce Commerce Cloud
Salesforce Commerce Cloud (SFCC, formerly Demandware) uses a cartridge-based architecture where functionality is layered through stackable code modules. Analytics tracking integrates through four mechanisms:
- ISML templates are SFCC's proprietary server-side templates (similar to JSP) that render HTML. Scripts are injected through template includes in the page head and footer
- SFRA (Storefront Reference Architecture) is the standard cartridge that provides the base storefront. Custom analytics cartridges overlay SFRA templates to add tracking without modifying the base code
- Business Manager is SFCC's admin interface where site preferences, content slots, and code versions are configured. Analytics IDs (GTM container, GA4 measurement) are typically stored as site preferences
dw.analyticsis the built-in server-side analytics API that captures page impressions, search events, and basket data for SFCC's native analytics dashboard (Analytics > Reports in Business Manager)
SFCC pages are assembled by controllers (SFRA uses CommonJS modules) that call res.render() with an ISML template name. The template engine resolves includes, loops, and expressions server-side. Page caching is controlled per-template via <iscache> tags. Cached pages bake in all inline scripts, so dynamic data layer values on cached pages must be resolved client-side or use AJAX calls to a non-cached controller endpoint.
For headless SFCC implementations (PWA Kit / Composable Storefront), analytics is handled entirely on the client via SCAPI (Shopper Commerce API) responses and React component lifecycle hooks.
Installing Tracking Scripts
Via SFRA Cartridge Overlay
Create an analytics cartridge that overrides the SFRA htmlHead.isml partial:
<!-- cartridges/app_analytics/cartridge/templates/default/common/htmlHead.isml -->
<isset name="gtmId" value="${dw.system.Site.current.getCustomPreferenceValue('gtmContainerID')}" scope="page" />
<isif condition="${!empty(gtmId)}">
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${gtmId}');</script>
</isif>
Add the noscript fallback in the body template:
<!-- cartridges/app_analytics/cartridge/templates/default/common/layout/page.isml -->
<isinclude template="common/htmlHead" />
</head>
<body>
<isset name="gtmId" value="${dw.system.Site.current.getCustomPreferenceValue('gtmContainerID')}" scope="page" />
<isif condition="${!empty(gtmId)}">
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
</isif>
<isreplace/>
</body>
Business Manager Site Preference Setup
Store the GTM container ID as a site preference so it can be changed without code deployment:
- In Business Manager, go to Administration > Site Development > System Object Types
- Select SitePreferences and add a custom attribute:
gtmContainerID(type: String) - Go to Merchant Tools > Site Preferences > Custom Preferences
- Enter your GTM container ID (e.g.,
GTM-XXXXXX)
The ISML templates above read this value with dw.system.Site.current.getCustomPreferenceValue('gtmContainerID').
Data Layer Setup
Server-Side via SFRA Controller
Create a controller that builds the data layer object from SFCC's server-side APIs and exposes it to the template:
// cartridges/app_analytics/cartridge/controllers/AnalyticsData.js
'use strict';
var server = require('server');
var Site = require('dw/system/Site');
var URLUtils = require('dw/web/URLUtils');
server.append('Show', function (req, res, next) {
var viewData = res.getViewData();
var currentSite = Site.current;
var currentCustomer = req.currentCustomer;
viewData.analyticsData = {
site_id: currentSite.ID,
locale: req.locale.id,
currency: currentSite.defaultCurrency,
page_url: req.httpURL.toString(),
customer_authenticated: currentCustomer.authenticated,
customer_registered: currentCustomer.registered
};
res.setViewData(viewData);
next();
});
module.exports = server.exports();
ISML Template Data Layer Push
In your page template, render the data layer from the controller's view data:
<!-- cartridges/app_analytics/cartridge/templates/default/common/dataLayer.isml -->
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'site_id': '${pdict.analyticsData.site_id}',
'locale': '${pdict.analyticsData.locale}',
'currency': '${pdict.analyticsData.currency}',
'page_url': '${pdict.analyticsData.page_url}',
'customer_authenticated': ${pdict.analyticsData.customer_authenticated},
'customer_registered': ${pdict.analyticsData.customer_registered}
});
</script>
Product Detail Page Data Layer
On product detail pages, extend the data layer with product-specific fields from the SFCC product API:
// cartridges/app_analytics/cartridge/controllers/Product.js
'use strict';
var server = require('server');
var ProductMgr = require('dw/catalog/ProductMgr');
server.append('Show', function (req, res, next) {
var viewData = res.getViewData();
var product = viewData.product;
if (product) {
viewData.productAnalytics = {
item_id: product.id,
item_name: product.productName,
item_brand: product.brand,
item_category: product.primaryCategory
? product.primaryCategory.displayName : '',
price: product.price.sales
? product.price.sales.value : product.price.list.value,
currency: product.price.sales
? product.price.sales.currency : product.price.list.currency
};
}
res.setViewData(viewData);
next();
});
module.exports = server.exports();
<!-- cartridges/app_analytics/cartridge/templates/default/product/productDetails.isml -->
<isif condition="${pdict.productAnalytics}">
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'view_item',
'ecommerce': {
'items': [{
'item_id': '${pdict.productAnalytics.item_id}',
'item_name': '<isprint value="${pdict.productAnalytics.item_name}" encoding="jshtml" />',
'item_brand': '<isprint value="${pdict.productAnalytics.item_brand}" encoding="jshtml" />',
'item_category': '<isprint value="${pdict.productAnalytics.item_category}" encoding="jshtml" />',
'price': ${pdict.productAnalytics.price},
'currency': '${pdict.productAnalytics.currency}'
}]
}
});
</script>
</isif>
Ecommerce Tracking
Purchase Event on Order Confirmation
SFRA's order confirmation controller passes the order object to the template. Extract purchase data in the confirmation ISML:
<!-- cartridges/app_analytics/cartridge/templates/default/checkout/confirmation/confirmation.isml -->
<isif condition="${pdict.order}">
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({ 'ecommerce': null }); // Clear previous ecommerce data
var items = [];
<isloop items="${pdict.order.items.items}" var="lineItem" status="loopState">
items.push({
'item_id': '${lineItem.id}',
'item_name': '<isprint value="${lineItem.productName}" encoding="jshtml" />',
'price': ${lineItem.priceTotal.price},
'quantity': ${lineItem.quantity}
});
</isloop>
dataLayer.push({
'event': 'purchase',
'ecommerce': {
'transaction_id': '${pdict.order.orderNumber}',
'value': ${pdict.order.totals.grandTotal.replace(/[^0-9.]/g, '')},
'currency': '${pdict.order.currencyCode}',
'shipping': ${pdict.order.totals.totalShippingCost.replace(/[^0-9.]/g, '')},
'tax': ${pdict.order.totals.totalTax.replace(/[^0-9.]/g, '')},
'items': items
}
});
</script>
</isif>
Add-to-Cart via Client-Side AJAX Hook
SFRA handles add-to-cart via AJAX. Hook into the cart update response:
// cartridges/app_analytics/cartridge/client/default/js/analyticsEvents.js
'use strict';
$(document).on('product:afterAddToCart', function (e, data) {
if (!data || !data.cart) return;
var addedProduct = data.cart.items[data.cart.items.length - 1];
window.dataLayer = window.dataLayer || [];
dataLayer.push({ 'ecommerce': null });
dataLayer.push({
'event': 'add_to_cart',
'ecommerce': {
'items': [{
'item_id': addedProduct.id,
'item_name': addedProduct.productName,
'price': parseFloat(addedProduct.priceTotal.price),
'quantity': addedProduct.quantity
}]
}
});
});
$(document).on('cart:afterRemoveItem', function (e, data) {
window.dataLayer = window.dataLayer || [];
dataLayer.push({ 'ecommerce': null });
dataLayer.push({
'event': 'remove_from_cart',
'ecommerce': {
'items': [{
'item_id': data.removedItem.id,
'item_name': data.removedItem.productName,
'price': parseFloat(data.removedItem.price),
'quantity': data.removedItem.quantity
}]
}
});
});
Include this script in SFRA's client-side bundle by requiring it in the main entry file or adding it to the webpack configuration of your analytics cartridge.
Common Errors
| Error | Cause | Fix |
|---|---|---|
ISML expression outputs null |
Product or order field is undefined in the view data | Wrap the data layer block in <isif condition="${pdict.fieldName}"> to check existence before rendering |
| Scripts missing after code version switch | Business Manager active code version does not include the analytics cartridge | Go to Administration > Code Deployment and verify the analytics cartridge is in the active code version's cartridge path |
| Data layer values HTML-encoded | ISML auto-escapes output by default | Use <isprint encoding="jshtml" /> for values inside JavaScript strings, or encoding="off" for trusted JSON output |
| GTM fires before data layer ready | GTM snippet placed before the data layer push in the ISML template include order | Ensure dataLayer.isml is included before htmlHead.isml in the page template, or use a GTM trigger that waits for a custom event |
| Purchase event fires on page reload | Order confirmation page is not a one-time view | Set a session attribute (session.custom.purchaseTracked) after the first push and check it before rendering the purchase script |
| Cartridge path order wrong | Analytics cartridge listed after app_storefront_base in the cartridge path, so SFRA templates override the analytics overlays |
Move the analytics cartridge before app_storefront_base in Business Manager > Administration > Sites > Manage Sites > Cartridge Path |
dw.analytics page impressions missing |
Built-in analytics disabled or the dw.analytics.track() call is not present in the controller |
Verify that dw.system.Site.current.getCustomPreferenceValue('analyticsEnabled') is true and SFRA's base Page-Show controller calls dw.analytics |
| Price values include currency symbols | ISML ${pdict.order.totals.grandTotal} returns formatted string like $125.00 |
Use .replace(/[^0-9.]/g, '') in JavaScript or access the raw numeric value from the order model before formatting |
| Staging sandbox tracks production GA4 | Same GTM container deployed to sandbox and production instances | Use GTM environments with separate container snippets per SFCC instance, or use a site preference that differs per instance |
| Client-side events not firing | Analytics JS file not included in the webpack build | Add the analytics module to the cartridge's webpack.config.js entry points and run npm run build |
Performance Considerations
- ISML page caching: Use
<iscache type="relative" hour="1" />on non-personalized pages but exclude it from templates that contain dynamic data layer values (cart contents, user state). Alternatively, cache the page shell and load the data layer via an AJAX call to an uncached controller - Cartridge stack depth: Each cartridge overlay in the path adds a template resolution lookup. Keep the analytics cartridge as a single overlay rather than splitting across multiple cartridges
- Client-side bundle size: SFRA's webpack build compiles all client JS into bundles. The analytics event listeners add minimal overhead (under 2KB gzipped), but avoid importing large analytics SDKs synchronously
dw.analyticsoverhead: SFCC's built-in analytics pipeline processes on every page request server-side. If you rely entirely on GA4/GTM and do not use Business Manager analytics reports, disabledw.analyticstracking to reduce server-side processing time- Minimize inline script size: SFCC counts inline script bytes toward the page weight budget. Keep data layer pushes under 1KB per page by only including fields you actually reference in GTM triggers and variables
- Use OCAPI/SCAPI for headless: In PWA Kit (Composable Storefront) implementations, all analytics happens client-side. Fetch product and order data from SCAPI endpoints and push to the data layer in React
useEffecthooks rather than server-rendering inline scripts