Learn how to implement a comprehensive data layer for Google Tag Manager on Episerver (Optimizely) CMS and Commerce.
Prerequisites
- GTM Setup completed
- Understanding of Episerver content models
- Basic JavaScript knowledge
Data Layer Overview
The data layer is a JavaScript object that stores information about the page, user, and events for GTM to access.
Basic Structure
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'eventName',
'property1': 'value1',
'property2': 'value2'
});
Page Load Data Layer
Initialize the data layer on every page load with common information.
Create Helper Class
using EPiServer;
using EPiServer.Core;
using EPiServer.Web;
using EPiServer.ServiceLocation;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
public class GTMDataLayerHelper
{
private readonly IContentLoader _contentLoader;
private readonly ISiteDefinitionRepository _siteRepository;
public GTMDataLayerHelper(
IContentLoader contentLoader,
ISiteDefinitionRepository siteRepository)
{
_contentLoader = contentLoader;
_siteRepository = siteRepository;
}
public object GetPageDataLayer(IContent content)
{
var page = content as PageData;
var currentSite = _siteRepository.Get(SiteDefinition.Current.Id);
var dataLayer = new
{
// Page Information
pageType = content.GetOriginalType().Name,
pageId = content.ContentLink.ID.ToString(),
pageName = content.Name,
pageUrl = GetPageUrl(content),
pageTemplate = page?.PageTypeName,
// Site Information
siteName = currentSite.Name,
siteId = currentSite.Id.ToString(),
language = (content as ILocalizable)?.Language?.Name ?? "en",
// Content Information
contentGuid = content.ContentGuid.ToString(),
contentVersion = (content as IVersionable)?.Status.ToString(),
publishedDate = (content as IChangeTrackable)?.Changed.ToString("yyyy-MM-dd"),
// Category/Structure
category = GetContentCategory(content),
breadcrumb = GetBreadcrumb(content),
// User Context
isEditMode = PageEditing.PageIsInEditMode,
isPreviewMode = ContextMode.Current == ContextMode.Preview,
// Episerver Version
cmsVersion = EPiServer.Framework.FrameworkVersion.Current.ToString()
};
return dataLayer;
}
public string GetPageDataLayerJson(IContent content)
{
var dataLayer = GetPageDataLayer(content);
return JsonConvert.SerializeObject(dataLayer, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
}
private string GetPageUrl(IContent content)
{
var urlResolver = ServiceLocator.Current.GetInstance<IUrlResolver>();
return urlResolver.GetUrl(content.ContentLink, null, new UrlResolverArguments
{
ContextMode = ContextMode.Default
});
}
private string GetContentCategory(IContent content)
{
// Implement based on your content structure
// Example: Return content area or categorization property
if (content is ICategorizable categorizable)
{
return string.Join(", ", categorizable.Category?.Select(c => c.Description) ?? new string[0]);
}
return null;
}
private string[] GetBreadcrumb(IContent content)
{
var breadcrumb = new List<string>();
var current = content;
while (current != null && !(current is PageData && ((PageData)current).IsStartPage()))
{
breadcrumb.Insert(0, current.Name);
var parent = _contentLoader.Get<IContent>(current.ParentLink);
current = parent;
}
return breadcrumb.ToArray();
}
}
Add to Master Layout
@using Newtonsoft.Json
@inject GTMDataLayerHelper DataLayerHelper
@{
var isEditMode = EPiServer.Editor.PageEditing.PageIsInEditMode;
}
@if (!isEditMode)
{
<script>
// Initialize data layer before GTM loads
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(@Html.Raw(DataLayerHelper.GetPageDataLayerJson(Model)));
</script>
}
<!-- GTM script here -->
Complete Example
<!DOCTYPE html>
<html>
<head>
@{
var gtmId = "GTM-XXXXXXX";
var isEditMode = EPiServer.Editor.PageEditing.PageIsInEditMode;
}
@if (!isEditMode)
{
<!-- Initialize Data Layer -->
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(@Html.Raw(DataLayerHelper.GetPageDataLayerJson(Model)));
</script>
<!-- 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>
}
</head>
<body>
<!-- Content -->
</body>
</html>
Commerce Data Layer
Product Data Layer Helper
using EPiServer.Commerce.Catalog.ContentTypes;
using EPiServer.Commerce.Order;
using Mediachase.Commerce;
using Mediachase.Commerce.Catalog;
public class GTMCommerceDataLayerHelper
{
private readonly IContentLoader _contentLoader;
private readonly ReferenceConverter _referenceConverter;
private readonly ICurrentMarket _currentMarket;
public GTMCommerceDataLayerHelper(
IContentLoader contentLoader,
ReferenceConverter referenceConverter,
ICurrentMarket currentMarket)
{
_contentLoader = contentLoader;
_referenceConverter = referenceConverter;
_currentMarket = currentMarket;
}
public object GetProductDataLayer(EntryContentBase entry)
{
var variant = entry as VariationContent;
var product = variant != null
? _contentLoader.Get<ProductContent>(variant.GetParentProducts().FirstOrDefault())
: entry as ProductContent;
var price = entry.GetDefaultPrice();
var stock = entry.GetStockPlacement()?.Quantity ?? 0;
return new
{
// Ecommerce object for GTM
ecommerce = new
{
currencyCode = _currentMarket.GetCurrentMarket().DefaultCurrency.CurrencyCode,
detail = new
{
products = new[]
{
new
{
id = entry.Code,
name = entry.DisplayName,
price = price?.UnitPrice.Amount,
brand = product?.Brand,
category = GetCategoryPath(entry),
variant = variant?.VariantName,
stockLevel = stock,
currency = price?.UnitPrice.Currency.CurrencyCode
}
}
}
},
// Additional product metadata
productType = entry.GetOriginalType().Name,
productId = entry.ContentLink.ID,
inStock = stock > 0
};
}
public object GetCartDataLayer(ICart cart)
{
var lineItems = cart.GetAllLineItems().Select((item, index) => new
{
id = item.Code,
name = item.DisplayName,
price = item.PlacedPrice,
quantity = item.Quantity,
variant = item.Properties["VariantName"]?.ToString(),
position = index + 1
}).ToArray();
return new
{
ecommerce = new
{
currencyCode = cart.Currency.CurrencyCode,
value = cart.GetTotal().Amount,
items = lineItems
},
cartId = cart.OrderLink.OrderGroupId,
cartItemCount = cart.GetAllLineItems().Sum(li => li.Quantity)
};
}
public object GetCheckoutDataLayer(ICart cart, int step)
{
var lineItems = cart.GetAllLineItems().Select(item => new
{
id = item.Code,
name = item.DisplayName,
price = item.PlacedPrice,
quantity = item.Quantity
}).ToArray();
var stepName = step switch
{
1 => "shipping",
2 => "payment",
3 => "review",
_ => "unknown"
};
return new
{
ecommerce = new
{
currencyCode = cart.Currency.CurrencyCode,
checkout = new
{
actionField = new
{
step = step,
option = stepName
},
products = lineItems
}
},
checkoutStep = step,
checkoutStepName = stepName
};
}
public object GetPurchaseDataLayer(IPurchaseOrder order)
{
var lineItems = order.GetAllLineItems().Select(item => new
{
id = item.Code,
name = item.DisplayName,
price = item.PlacedPrice,
quantity = item.Quantity,
variant = item.Properties["VariantName"]?.ToString()
}).ToArray();
var shipping = order.GetFirstShipment()?.ShippingSubTotal.Amount ?? 0;
var tax = order.TaxTotal.Amount;
return new
{
ecommerce = new
{
currencyCode = order.Currency.CurrencyCode,
purchase = new
{
actionField = new
{
id = order.OrderNumber,
affiliation = order.MarketName,
revenue = order.GetTotal().Amount,
tax = tax,
shipping = shipping,
coupon = order.GetFirstForm()?.CouponCodes.FirstOrDefault()
},
products = lineItems
}
},
transactionId = order.OrderNumber,
orderStatus = order.Status.ToString()
};
}
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);
}
}
Product Page Data Layer
@model ProductPageViewModel
@inject GTMCommerceDataLayerHelper CommerceDataLayer
@{
var isEditMode = EPiServer.Editor.PageEditing.PageIsInEditMode;
}
@if (!isEditMode)
{
<script>
// Push product view to data layer
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(@Html.Raw(JsonConvert.SerializeObject(
CommerceDataLayer.GetProductDataLayer(Model.Product),
new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
}
)));
// Trigger GTM event
window.dataLayer.push({
'event': 'productView'
});
</script>
}
Cart Page Data Layer
@model CartPageViewModel
@inject GTMCommerceDataLayerHelper CommerceDataLayer
@{
var cart = Model.Cart;
var isEditMode = EPiServer.Editor.PageEditing.PageIsInEditMode;
}
@if (!isEditMode && cart != null)
{
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(@Html.Raw(JsonConvert.SerializeObject(
CommerceDataLayer.GetCartDataLayer(cart),
new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
}
)));
window.dataLayer.push({
'event': 'cartView'
});
</script>
}
Event Tracking via Data Layer
Custom Event Helper
// /Scripts/gtm-events.js
window.GTMEvents = window.GTMEvents || {};
GTMEvents.push = function(eventName, eventData) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': eventName,
...eventData
});
};
// Convenience methods
GTMEvents.trackClick = function(elementType, elementName, elementId) {
this.push('elementClick', {
'elementType': elementType,
'elementName': elementName,
'elementId': elementId,
'pageUrl': window.location.pathname
});
};
GTMEvents.trackFormSubmit = function(formName, formId) {
this.push('formSubmit', {
'formName': formName,
'formId': formId,
'formUrl': window.location.pathname
});
};
GTMEvents.trackDownload = function(fileName, fileUrl) {
this.push('fileDownload', {
'fileName': fileName,
'fileUrl': fileUrl,
'fileExtension': fileName.split('.').pop()
});
};
GTMEvents.trackSearch = function(searchTerm, resultCount) {
this.push('siteSearch', {
'searchTerm': searchTerm,
'searchResults': resultCount,
'searchLocation': window.location.pathname
});
};
GTMEvents.trackVideo = function(action, videoTitle, videoUrl) {
this.push('videoInteraction', {
'videoAction': action,
'videoTitle': videoTitle,
'videoUrl': videoUrl
});
};
GTMEvents.trackOutbound = function(url, linkText) {
this.push('outboundClick', {
'outboundUrl': url,
'linkText': linkText,
'linkDomain': new URL(url).hostname
});
};
Usage in Templates
<button 'Subscribe', 'subscribe-btn')">
Subscribe
</button>
<a href="/documents/file.pdf" this.href)">
Download PDF
</a>
<form id="contact-form" Form', 'contact-form')">
<!-- Form fields -->
</form>
Block-Specific Data Layer
Track data layer information for content blocks.
Block Data Layer Attribute
public class BlockDataLayerAttribute : Attribute
{
public string EventName { get; set; }
public bool TrackImpression { get; set; } = false;
public bool TrackInteraction { get; set; } = true;
public BlockDataLayerAttribute(string eventName)
{
EventName = eventName;
}
}
Block Template with Data Layer
@model HeroBlockType
@{
var blockId = Model.ContentLink.ID;
var blockType = Model.GetOriginalType().Name;
}
<div class="hero-block"
data-block-id="@blockId"
data-block-type="@blockType"
data-gtm-event="blockInteraction">
<h2>@Model.Heading</h2>
<button {
'blockType': '@blockType',
'blockId': '@blockId',
'blockName': '@Model.Name',
'buttonText': '@Model.ButtonText'
});">
@Model.ButtonText
</button>
</div>
<script>
// Track block impression
(function() {
var blockElement = document.querySelector('[data-block-id="@blockId"]');
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
GTMEvents.push('blockImpression', {
'blockType': '@blockType',
'blockId': '@blockId',
'blockName': '@Model.Name'
});
observer.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
observer.observe(blockElement);
})();
</script>
User Information Data Layer
Add authenticated user information (respecting privacy).
public object GetUserDataLayer()
{
var user = HttpContext.Current.User;
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
if (!isAuthenticated)
{
return new
{
userStatus = "guest",
userId = null,
userType = "anonymous"
};
}
var contact = CustomerContext.Current.CurrentContact;
return new
{
userStatus = "logged_in",
userId = contact?.PrimaryKeyId.ToString(), // Hashed or anonymized
userType = contact?.ContactType,
// DO NOT include PII like name, email, etc.
};
}
Add to page data layer:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
@Html.Raw(DataLayerHelper.GetPageDataLayerJson(Model)),
...@Html.Raw(JsonConvert.SerializeObject(DataLayerHelper.GetUserDataLayer()))
});
</script>
Personalization Data Layer
Track personalized content and visitor groups.
public object GetPersonalizationDataLayer()
{
var visitor = ServiceLocator.Current.GetInstance<IVisitorGroupRepository>();
var visitorGroups = visitor.List()
.Where(vg => vg.IsMatch(HttpContext.Current.User))
.Select(vg => vg.Name)
.ToArray();
return new
{
visitorGroups = visitorGroups,
personalized = visitorGroups.Any(),
activeExperiments = GetActiveExperiments()
};
}
private string[] GetActiveExperiments()
{
// Integrate with Optimizely Web Experimentation if used
return new string[0];
}
Form Tracking Data Layer
Track Episerver Forms via data layer.
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class FormDataLayerInitialization : IInitializableModule
{
public void Initialize(InitializationEngine context)
{
FormsEvents.Instance.FormSubmitted += OnFormSubmitted;
}
private void OnFormSubmitted(object sender, FormsEventArgs e)
{
var script = $@"
<script>
GTMEvents.push('formSubmitted', {{
'formName': '{e.FormsContent.Name}',
'formId': '{e.FormsContent.ContentLink.ID}',
'formType': 'episerver_form'
}});
</script>
";
HttpContext.Current.Response.Write(script);
}
public void Uninitialize(InitializationEngine context)
{
FormsEvents.Instance.FormSubmitted -= OnFormSubmitted;
}
}
Testing Data Layer
1. Browser Console
// View data layer
console.log(window.dataLayer);
// View latest push
console.log(window.dataLayer[window.dataLayer.length - 1]);
// Push test event
window.dataLayer.push({
'event': 'testEvent',
'testData': 'test value'
});
2. GTM Preview Mode
- Enable GTM Preview
- Visit your site
- Check "Data Layer" tab
- Verify your variables appear
3. Data Layer Viewer Extension
Install browser extension:
Best Practices
1. Naming Conventions
Use camelCase for consistency:
{
'event': 'buttonClick',
'elementType': 'cta',
'elementName': 'subscribe'
}
2. Data Layer Structure
Keep it flat when possible:
// Good
{
'productId': '123',
'productName': 'Widget'
}
// Avoid deep nesting unless necessary
{
'product': {
'details': {
'identification': {
'id': '123'
}
}
}
}
3. Avoid PII
Never include personally identifiable information:
- Email addresses
- Full names
- Phone numbers
- Addresses
Use hashed IDs instead:
var hashedUserId = GetHashedUserId(contact.Email);
4. Initialize Early
Initialize data layer BEFORE GTM loads:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ /* initial data */ });
</script>
<!-- GTM script here -->
5. Use Events
Push events for actions:
dataLayer.push({
'event': 'productAdded',
'productId': '123'
});
Common Data Layer Variables
Create these GTM variables to access data layer values:
| Variable Name | Type | Data Layer Variable |
|---|---|---|
| Page Type | Data Layer | pageType |
| Page ID | Data Layer | pageId |
| Site Name | Data Layer | siteName |
| Language | Data Layer | language |
| User Status | Data Layer | userStatus |
| Product ID | Data Layer | ecommerce.detail.products.0.id |
| Cart Total | Data Layer | ecommerce.value |
Next Steps
- GA4 Setup - Configure GA4 tags
- Meta Pixel - Add Meta Pixel
- Event Tracking - Track custom events
- Troubleshooting - Fix issues