Implement comprehensive GA4 ecommerce tracking for Episerver Commerce (now Optimizely Commerce) including products, cart, checkout, and purchases.
Prerequisites
- Episerver Commerce 13+ or Commerce 14+
- GA4 Setup completed
- Access to product templates, cart, and checkout pages
- Understanding of Episerver Commerce catalog structure
Ecommerce Data Layer Structure
Base Helper Class
Create a helper class to convert Episerver Commerce objects to GA4 format.
using EPiServer.Commerce.Catalog.ContentTypes;
using EPiServer.Commerce.Order;
using Mediachase.Commerce;
using Mediachase.Commerce.Catalog;
public class GA4EcommerceHelper
{
private readonly IContentLoader _contentLoader;
private readonly ReferenceConverter _referenceConverter;
private readonly ICurrentMarket _currentMarket;
public GA4EcommerceHelper(
IContentLoader contentLoader,
ReferenceConverter referenceConverter,
ICurrentMarket currentMarket)
{
_contentLoader = contentLoader;
_referenceConverter = referenceConverter;
_currentMarket = currentMarket;
}
public object GetGA4Item(EntryContentBase entry, decimal quantity = 1, int index = 0)
{
var variant = entry as VariationContent;
var product = variant != null
? _contentLoader.Get<ProductContent>(variant.GetParentProducts().FirstOrDefault())
: entry as ProductContent;
var price = entry.GetDefaultPrice()?.UnitPrice.Amount ?? 0;
return new
{
item_id = entry.Code,
item_name = entry.DisplayName,
affiliation = _currentMarket.GetCurrentMarket().MarketName,
item_brand = product?.Brand,
item_category = GetCategoryPath(entry),
item_variant = variant?.VariantName,
price = price,
quantity = quantity,
index = index
};
}
public object GetGA4Item(ILineItem lineItem, int index = 0)
{
var entry = _contentLoader.Get<EntryContentBase>(
_referenceConverter.GetContentLink(lineItem.Code));
var variant = entry as VariationContent;
var product = variant != null
? _contentLoader.Get<ProductContent>(variant.GetParentProducts().FirstOrDefault())
: entry as ProductContent;
return new
{
item_id = lineItem.Code,
item_name = lineItem.DisplayName,
affiliation = lineItem.OrderGroupId > 0 ? "Store" : "Online",
item_brand = product?.Brand,
item_category = GetCategoryPath(entry),
item_variant = variant?.VariantName,
price = lineItem.PlacedPrice,
quantity = lineItem.Quantity,
discount = lineItem.GetDiscountTotal().Amount,
index = index
};
}
private string GetCategoryPath(EntryContentBase entry)
{
var category = entry.GetCategories()
.Select(c => _contentLoader.Get<NodeContent>(c))
.FirstOrDefault();
if (category == null) return string.Empty;
var path = new List<string>();
var current = category;
while (current != null && !(current is CatalogContent))
{
path.Insert(0, current.DisplayName);
var parent = _contentLoader.Get<IContent>(current.ParentLink);
current = parent as NodeContent;
}
return string.Join("/", path);
}
}
View Model Extension
Create view model extensions for easy template usage.
public static class GA4ViewModelExtensions
{
public static string ToGA4Json(this object obj)
{
return JsonConvert.SerializeObject(obj, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
}
public static string GetGA4ItemsJson(this IEnumerable<ILineItem> lineItems, GA4EcommerceHelper helper)
{
var items = lineItems.Select((item, index) => helper.GetGA4Item(item, index)).ToList();
return items.ToGA4Json();
}
}
Product Views
Track when users view product detail pages.
Product Template
@using EPiServer.Commerce.Catalog.ContentTypes
@using Newtonsoft.Json
@model ProductContent
@{
var variant = Model.GetVariants()
.Select(v => _contentLoader.Get<VariationContent>(v))
.FirstOrDefault();
var price = variant?.GetDefaultPrice()?.UnitPrice.Amount ?? 0;
var ga4Helper = ServiceLocator.Current.GetInstance<GA4EcommerceHelper>();
var item = ga4Helper.GetGA4Item(variant ?? Model as EntryContentBase);
}
<script>
// Track product view
gtag('event', 'view_item', {
currency: '@_currentMarket.GetCurrentMarket().DefaultCurrency.CurrencyCode',
value: @price,
items: [@Html.Raw(item.ToGA4Json())]
});
</script>
<!-- Product details markup -->
<div class="product-detail">
<h1>@Model.DisplayName</h1>
<p class="price">@price.ToString("C")</p>
@if (variant != null)
{
<button class="add-to-cart"
data-code="@variant.Code" '@variant.DisplayName', @price)">
Add to Cart
</button>
}
</div>
Product List Views
Track product impressions on category, search, and listing pages.
Category Page Template
@model CategoryPageType
@{
var products = Model.GetProducts();
var ga4Helper = ServiceLocator.Current.GetInstance<GA4EcommerceHelper>();
var items = products.Select((p, index) => ga4Helper.GetGA4Item(p, 1, index + 1)).ToList();
}
<script>
// Track product list view
gtag('event', 'view_item_list', {
item_list_id: '@Model.ContentLink.ID',
item_list_name: '@Model.DisplayName',
items: @Html.Raw(items.ToGA4Json())
});
</script>
<div class="product-grid">
@foreach (var product in products.Select((p, index) => new { Product = p, Index = index }))
{
<div class="product-card" '@Model.DisplayName', @product.Index)">
<img src="@product.Product.ThumbnailUrl" alt="@product.Product.DisplayName" />
<h3>@product.Product.DisplayName</h3>
<p class="price">@product.Product.GetDefaultPrice()?.UnitPrice.Amount.ToString("C")</p>
</div>
}
</div>
<script>
function selectProduct(itemId, listName, index) {
gtag('event', 'select_item', {
item_list_id: '@Model.ContentLink.ID',
item_list_name: listName,
items: [{
item_id: itemId,
index: index
}]
});
}
</script>
Add to Cart
Track when products are added to the cart.
Client-Side Implementation
// /Scripts/ga4-commerce.js
window.EpiserverCommerce = window.EpiserverCommerce || {};
EpiserverCommerce.addToCart = function(code, name, price, quantity) {
quantity = quantity || 1;
// Call your cart API
fetch('/api/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: code,
quantity: quantity
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Track add to cart
gtag('event', 'add_to_cart', {
currency: data.currency || 'USD',
value: price * quantity,
items: [{
item_id: code,
item_name: name,
price: price,
quantity: quantity
}]
});
// Update cart UI
updateCartCount(data.cartItemCount);
}
})
.catch(error => {
console.error('Error adding to cart:', error);
});
};
Server-Side Controller
public class CartApiController : ApiController
{
private readonly IOrderRepository _orderRepository;
private readonly IOrderGroupFactory _orderGroupFactory;
private readonly IContentLoader _contentLoader;
private readonly ReferenceConverter _referenceConverter;
[HttpPost]
[Route("api/cart/add")]
public IHttpActionResult AddToCart([FromBody] AddToCartRequest request)
{
var cart = _orderRepository.LoadOrCreateCart<ICart>(
CustomerContext.Current.CurrentContactId,
"Default");
var contentLink = _referenceConverter.GetContentLink(request.Code);
var entry = _contentLoader.Get<EntryContentBase>(contentLink);
if (entry == null)
{
return BadRequest("Product not found");
}
var lineItem = cart.CreateLineItem(request.Code, _orderGroupFactory);
lineItem.Quantity = request.Quantity;
cart.AddLineItem(lineItem);
_orderRepository.Save(cart);
return Ok(new
{
success = true,
cartItemCount = cart.GetAllLineItems().Sum(li => li.Quantity),
currency = cart.Currency.CurrencyCode
});
}
}
public class AddToCartRequest
{
public string Code { get; set; }
public decimal Quantity { get; set; }
}
Remove from Cart
Track cart removals.
EpiserverCommerce.removeFromCart = function(code, name, price, quantity) {
fetch('/api/cart/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: code })
})
.then(response => response.json())
.then(data => {
if (data.success) {
gtag('event', 'remove_from_cart', {
currency: data.currency || 'USD',
value: price * quantity,
items: [{
item_id: code,
item_name: name,
price: price,
quantity: quantity
}]
});
}
});
};
View Cart
Track when users view their shopping cart.
Cart Page Template
@model CartPageType
@{
var cart = _orderRepository.LoadCart<ICart>(
CustomerContext.Current.CurrentContactId,
"Default");
var ga4Helper = ServiceLocator.Current.GetInstance<GA4EcommerceHelper>();
var items = cart.GetAllLineItems().GetGA4ItemsJson(ga4Helper);
}
<script>
gtag('event', 'view_cart', {
currency: '@cart.Currency.CurrencyCode',
value: @cart.GetTotal().Amount,
items: @Html.Raw(items)
});
</script>
<div class="cart">
<h1>Shopping Cart</h1>
@foreach (var lineItem in cart.GetAllLineItems())
{
<div class="cart-item">
<span>@lineItem.DisplayName</span>
<span>@lineItem.Quantity</span>
<span>@lineItem.PlacedPrice.ToString("C")</span>
<button
'@lineItem.Code',
'@lineItem.DisplayName',
@lineItem.PlacedPrice,
@lineItem.Quantity)">
Remove
</button>
</div>
}
<div class="cart-total">
<strong>Total: @cart.GetTotal().Amount.ToString("C")</strong>
</div>
<a href="@Url.ContentUrl(Model.CheckoutPage)" class="btn-checkout">
Proceed to Checkout
</a>
</div>
Begin Checkout
Track when users start the checkout process.
@model CheckoutPageType
@{
var cart = _orderRepository.LoadCart<ICart>(
CustomerContext.Current.CurrentContactId,
"Default");
var ga4Helper = ServiceLocator.Current.GetInstance<GA4EcommerceHelper>();
var items = cart.GetAllLineItems().GetGA4ItemsJson(ga4Helper);
}
<script>
gtag('event', 'begin_checkout', {
currency: '@cart.Currency.CurrencyCode',
value: @cart.GetTotal().Amount,
items: @Html.Raw(items)
});
</script>
Add Shipping Info
Track shipping information step.
EpiserverCommerce.addShippingInfo = function(cartData) {
gtag('event', 'add_shipping_info', {
currency: cartData.currency,
value: cartData.total,
shipping_tier: cartData.shippingMethod,
items: cartData.items
});
};
// Call when shipping method is selected
document.querySelector('#shipping-method').addEventListener('change', function(e) {
var cartData = getCartData(); // Your function to get cart data
cartData.shippingMethod = e.target.value;
EpiserverCommerce.addShippingInfo(cartData);
});
Add Payment Info
Track payment information step.
EpiserverCommerce.addPaymentInfo = function(cartData, paymentMethod) {
gtag('event', 'add_payment_info', {
currency: cartData.currency,
value: cartData.total,
payment_type: paymentMethod,
items: cartData.items
});
};
// Call when payment method is selected
document.querySelector('#payment-method').addEventListener('change', function(e) {
var cartData = getCartData();
EpiserverCommerce.addPaymentInfo(cartData, e.target.value);
});
Purchase Tracking
Track completed purchases on the order confirmation page.
Order Confirmation Page
@model OrderConfirmationPageType
@{
var orderId = RouteData.Values["orderNumber"]?.ToString();
if (!string.IsNullOrEmpty(orderId))
{
var order = _orderRepository.Load<IPurchaseOrder>(orderId);
if (order != null)
{
var ga4Helper = ServiceLocator.Current.GetInstance<GA4EcommerceHelper>();
var items = order.GetAllLineItems().GetGA4ItemsJson(ga4Helper);
var shipping = order.GetFirstShipment()?.ShippingSubTotal.Amount ?? 0;
var tax = order.TaxTotal.Amount;
var discount = order.GetTotal().Amount - order.SubTotal.Amount - shipping - tax;
}
}
}
@if (order != null)
{
<script>
gtag('event', 'purchase', {
transaction_id: '@order.OrderNumber',
affiliation: '@order.MarketName',
value: @order.GetTotal().Amount,
tax: @tax,
shipping: @shipping,
currency: '@order.Currency.CurrencyCode',
coupon: '@(order.GetFirstForm()?.CouponCodes.FirstOrDefault())',
items: @Html.Raw(items)
});
</script>
<div class="order-confirmation">
<h1>Thank You for Your Order!</h1>
<p>Order Number: <strong>@order.OrderNumber</strong></p>
<p>Total: <strong>@order.GetTotal().Amount.ToString("C")</strong></p>
<div class="order-items">
@foreach (var lineItem in order.GetAllLineItems())
{
<div class="order-item">
<span>@lineItem.DisplayName</span>
<span>Qty: @lineItem.Quantity</span>
<span>@lineItem.PlacedPrice.ToString("C")</span>
</div>
}
</div>
</div>
}
Prevent Duplicate Purchase Events
Store tracked orders to prevent duplicate events on page refresh.
public class PurchaseTrackingFilter : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var orderId = filterContext.RouteData.Values["orderNumber"]?.ToString();
if (!string.IsNullOrEmpty(orderId))
{
var cookieName = $"ga4_tracked_{orderId}";
var cookie = filterContext.HttpContext.Request.Cookies[cookieName];
if (cookie != null)
{
// Already tracked, don't track again
filterContext.HttpContext.Items["OrderAlreadyTracked"] = true;
}
else
{
// Set cookie to prevent duplicate tracking
filterContext.HttpContext.Response.Cookies.Add(new HttpCookie(cookieName, "1")
{
Expires = DateTime.Now.AddDays(1)
});
}
}
}
}
Apply to controller:
[PurchaseTrackingFilter]
public ActionResult OrderConfirmation(string orderNumber)
{
// Your code
}
Update template:
@if (order != null && HttpContext.Items["OrderAlreadyTracked"] == null)
{
<script>
gtag('event', 'purchase', {
// ... purchase data
});
</script>
}
Refund Tracking
Track refunds (typically done server-side or via backend process).
public class OrderEventHandler
{
public void OnOrderRefunded(IPurchaseOrder order, decimal refundAmount)
{
// Log refund for manual import to GA4
// or use Measurement Protocol API
var refundData = new
{
transaction_id = order.OrderNumber,
value = refundAmount,
currency = order.Currency.CurrencyCode
};
// Option 1: Log to file for batch import
LogRefund(refundData);
// Option 2: Send via Measurement Protocol
SendRefundToGA4(refundData);
}
private void SendRefundToGA4(object refundData)
{
// Use GA4 Measurement Protocol
// https://developers.google.com/analytics/devguides/collection/protocol/ga4
}
}
Product Impressions in Blocks
Track product impressions in recommendation blocks or featured products.
@model ProductRecommendationBlock
@{
var products = Model.RecommendedProducts;
var ga4Helper = ServiceLocator.Current.GetInstance<GA4EcommerceHelper>();
var items = products.Select((p, index) =>
{
var entry = _contentLoader.Get<EntryContentBase>(p);
return ga4Helper.GetGA4Item(entry, 1, index + 1);
}).ToList();
}
<script>
gtag('event', 'view_item_list', {
item_list_id: 'recommendations_@Model.ContentLink.ID',
item_list_name: '@Model.Name',
items: @Html.Raw(items.ToGA4Json())
});
</script>
<div class="product-recommendations">
@foreach (var productRef in products.Select((p, index) => new { Ref = p, Index = index }))
{
var product = _contentLoader.Get<EntryContentBase>(productRef.Ref);
<div class="product-card" @productRef.Index)">
<img src="@product.ThumbnailUrl" alt="@product.DisplayName" />
<h4>@product.DisplayName</h4>
</div>
}
</div>
<script>
function selectRecommendation(itemId, index) {
gtag('event', 'select_item', {
item_list_id: 'recommendations_@Model.ContentLink.ID',
item_list_name: '@Model.Name',
items: [{
item_id: itemId,
index: index
}]
});
}
</script>
Promotions
Track promotion views and selections.
@{
var promotion = Model.ActivePromotion;
}
<script>
// View promotion
gtag('event', 'view_promotion', {
creative_name: '@promotion.Name',
creative_slot: 'banner_top',
promotion_id: '@promotion.Id',
promotion_name: '@promotion.Name'
});
</script>
<div class="promotion-banner" '@promotion.Name')">
@promotion.Content
</div>
<script>
function selectPromotion(promoId, promoName) {
gtag('event', 'select_promotion', {
creative_name: promoName,
creative_slot: 'banner_top',
promotion_id: promoId,
promotion_name: promoName
});
}
</script>
Testing Ecommerce Tracking
1. GA4 DebugView
Enable debug mode and test each event:
- Product views
- Add to cart
- Cart view
- Checkout steps
- Purchase
2. Browser Console
// Check data layer
console.log(window.dataLayer);
// Manually trigger test purchase
gtag('event', 'purchase', {
transaction_id: 'TEST_' + Date.now(),
value: 99.99,
currency: 'USD',
items: [{
item_id: 'TEST_SKU',
item_name: 'Test Product',
price: 99.99,
quantity: 1
}]
});
3. Network Tab
Monitor collect endpoint calls and verify ecommerce parameters.
Best Practices
- Test Mode: Create a separate GA4 property for testing
- Currency: Always include currency code
- Product IDs: Use consistent product codes/SKUs
- Categories: Include full category path when available
- Variants: Track product variants separately
- Refunds: Implement refund tracking for accurate revenue
- Duplicate Prevention: Prevent duplicate purchase events
- Privacy: Respect user consent for ecommerce tracking
Next Steps
- GTM Implementation - Use GTM for commerce tracking
- Event Tracking - Additional events
- Troubleshooting - Fix tracking issues