Learn how to build a robust GTM data layer using Craft CMS data, Twig templates, and best practices for structured data implementation.
Overview
The GTM data layer is a JavaScript object that contains structured information about your website, users, and content. In Craft CMS, you can populate this data layer with rich content metadata, user information, and e-commerce data.
Basic Data Layer Structure
Initialize Data Layer
Create a base data layer template:
{# templates/_analytics/data-layer.twig #}
{% set environment = craft.app.config.general.environment %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'dataLayer_initialized',
'site': {
'name': '{{ siteName|e('js') }}',
'handle': '{{ currentSite.handle }}',
'language': '{{ currentSite.language }}',
'baseUrl': '{{ siteUrl }}',
'environment': '{{ environment }}'
},
'page': {
'path': '{{ craft.app.request.pathInfo }}',
'url': '{{ craft.app.request.absoluteUrl }}',
'referrer': '{{ craft.app.request.referrer ?? '' }}',
'title': '{{ title ?? siteName|e('js') }}'
},
'timestamp': {{ now|date('U') }}
});
</script>
Include in your base layout before GTM:
{# templates/_layouts/base.twig #}
<!DOCTYPE html>
<html>
<head>
{# Initialize data layer first #}
{{ include('_analytics/data-layer') }}
{# Then load GTM #}
{{ include('_analytics/google-tag-manager') }}
</head>
<body>
{# ... #}
</body>
</html>
Entry-Specific Data Layer
Blog Post Data Layer
{# templates/blog/_entry.twig #}
{% if entry is defined %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'entry_loaded',
'entry': {
'id': '{{ entry.id }}',
'title': '{{ entry.title|e('js') }}',
'slug': '{{ entry.slug }}',
'type': '{{ entry.type.handle }}',
'section': '{{ entry.section.handle }}',
'url': '{{ entry.url }}',
'postDate': '{{ entry.postDate|date('Y-m-d') }}',
'author': {
'id': '{{ entry.author.id }}',
'name': '{{ entry.author.fullName|e('js') }}',
'username': '{{ entry.author.username }}'
},
{% if entry.category is defined and entry.category.exists() %}
'categories': [
{% for category in entry.category.all() %}
{
'id': '{{ category.id }}',
'title': '{{ category.title|e('js') }}',
'slug': '{{ category.slug }}'
}{{ not loop.last ? ',' : '' }}
{% endfor %}
],
{% endif %}
{% if entry.tags is defined and entry.tags.exists() %}
'tags': [
{% for tag in entry.tags.all() %}
'{{ tag.title|e('js') }}'{{ not loop.last ? ',' : '' }}
{% endfor %}
],
{% endif %}
'wordCount': {{ entry.body|striptags|split(' ')|length }},
'readTime': {{ (entry.body|striptags|split(' ')|length / 200)|round }}
}
});
</script>
{% endif %}
Universal Entry Data Layer Macro
Create a reusable macro for any entry type:
{# templates/_macros/data-layer.twig #}
{% macro entryData(entry) %}
{
'id': '{{ entry.id }}',
'title': '{{ entry.title|e('js') }}',
'slug': '{{ entry.slug }}',
'uri': '{{ entry.uri }}',
'url': '{{ entry.url }}',
'type': '{{ entry.type.handle }}',
'section': '{{ entry.section.handle }}',
'postDate': '{{ entry.postDate|date('Y-m-d') }}',
'expiryDate': '{{ entry.expiryDate ? entry.expiryDate|date('Y-m-d') : null }}',
'author': {
'id': '{{ entry.author.id }}',
'name': '{{ entry.author.fullName|e('js') }}'
},
'status': '{{ entry.status }}'
}
{% endmacro %}
Use in templates:
{% import '_macros/data-layer' as dl %}
<script>
window.dataLayer.push({
'event': 'entry_view',
'entry': {{ dl.entryData(entry)|raw }}
});
</script>
User Data Layer
Logged-In User Information
{# templates/_analytics/user-data-layer.twig #}
{% set currentUser = currentUser ?? null %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'user': {
{% if currentUser %}
'status': 'logged_in',
'id': '{{ currentUser.id }}',
'username': '{{ currentUser.username }}',
'email': '{{ currentUser.email|hash('sha256') }}', {# Hashed for privacy #}
'groups': [
{% for group in currentUser.groups %}
'{{ group.handle }}'{{ not loop.last ? ',' : '' }}
{% endfor %}
],
'permissions': [
{% if currentUser.can('accessCp') %}'access_cp',{% endif %}
{% if currentUser.can('editEntries') %}'edit_entries',{% endif %}
{# Add other relevant permissions #}
]
{% else %}
'status': 'logged_out',
'id': null
{% endif %}
}
});
</script>
User Preferences and Attributes
{# Custom user fields in data layer #}
{% if currentUser %}
<script>
window.dataLayer.push({
'user': {
'preferences': {
'newsletter': {{ currentUser.newsletterOptIn ?? false ? 'true' : 'false' }},
'language': '{{ currentUser.preferredLanguage ?? currentSite.language }}',
'timezone': '{{ currentUser.timezone ?? craft.app.timezone }}'
},
'profile': {
'memberSince': '{{ currentUser.dateCreated|date('Y-m-d') }}',
'lastLogin': '{{ currentUser.lastLoginDate|date('Y-m-d H:i:s') }}'
}
}
});
</script>
{% endif %}
E-commerce Data Layer (Craft Commerce)
Product Page Data Layer
{# templates/shop/product.twig #}
{% set variant = product.defaultVariant %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'view_item',
'ecommerce': {
'currency': '{{ craft.commerce.carts.cart.currency }}',
'value': {{ variant.price|number_format(2, '.', '') }},
'items': [{
'item_id': '{{ variant.sku ?? variant.id }}',
'item_name': '{{ product.title|e('js') }}',
'item_variant': '{{ variant.title|e('js') }}',
'price': {{ variant.price|number_format(2, '.', '') }},
'item_brand': '{{ product.brand ?? siteName }}',
'item_category': '{{ product.productType.name }}',
{% if product.productCategory.first() %}
'item_category2': '{{ product.productCategory.first().title }}',
{% endif %}
'quantity': 1,
'stock_status': '{{ variant.stock > 0 ? 'in_stock' : 'out_of_stock' }}',
'stock_quantity': {{ variant.stock ?? 0 }}
}]
},
'product': {
'id': '{{ product.id }}',
'sku': '{{ variant.sku }}',
'name': '{{ product.title|e('js') }}',
'price': {{ variant.price|number_format(2, '.', '') }},
'available': {{ variant.isAvailable ? 'true' : 'false' }},
'variantCount': {{ product.variants|length }}
}
});
</script>
Cart Data Layer
{# templates/_analytics/cart-data-layer.twig #}
{% set cart = craft.commerce.carts.cart %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'cart': {
'id': '{{ cart.id }}',
'number': '{{ cart.number }}',
'itemCount': {{ cart.totalQty }},
'subtotal': {{ cart.itemSubtotal|number_format(2, '.', '') }},
'total': {{ cart.total|number_format(2, '.', '') }},
'currency': '{{ cart.currency }}',
'couponCode': '{{ cart.couponCode ?? '' }}',
'items': [
{% for item in cart.lineItems %}
{
'id': '{{ item.purchasable.sku ?? item.purchasable.id }}',
'name': '{{ item.description|e('js') }}',
'price': {{ item.price|number_format(2, '.', '') }},
'quantity': {{ item.qty }}
}{{ not loop.last ? ',' : '' }}
{% endfor %}
]
}
});
</script>
Search Data Layer
Track search queries and results:
{# templates/search/index.twig #}
{% set searchQuery = craft.app.request.getParam('q') %}
{% set searchResults = craft.entries()
.search(searchQuery)
.all() %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'search',
'search': {
'term': '{{ searchQuery|e('js') }}',
'results_count': {{ searchResults|length }},
'has_results': {{ searchResults|length > 0 ? 'true' : 'false' }},
'sections_searched': [
{% set sections = searchResults|group('section.handle') %}
{% for handle, entries in sections %}
{
'section': '{{ handle }}',
'count': {{ entries|length }}
}{{ not loop.last ? ',' : '' }}
{% endfor %}
]
}
});
</script>
Matrix Fields in Data Layer
Track Matrix block usage on entries:
{# Data layer for Matrix content blocks #}
{% if entry.contentBlocks is defined %}
<script>
window.dataLayer.push({
'entry': {
'matrixBlocks': {
'total': {{ entry.contentBlocks.all()|length }},
'types': [
{% set blockTypes = entry.contentBlocks.all()|group('type.handle') %}
{% for handle, blocks in blockTypes %}
{
'type': '{{ handle }}',
'count': {{ blocks|length }}
}{{ not loop.last ? ',' : '' }}
{% endfor %}
]
}
}
});
</script>
{% endif %}
GraphQL API Data Layer
For headless Craft implementations, return data layer structure via GraphQL:
query DataLayerQuery($uri: [String]) {
entry(uri: $uri) {
id
title
slug
uri
typeHandle
sectionHandle
postDate @formatDateTime(format: "Y-m-d")
author {
id
fullName
}
... on blog_blog_Entry {
categories {
id
title
slug
}
tags {
id
title
}
}
}
}
Build data layer in frontend:
// Frontend JavaScript
function buildDataLayer(entryData) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'entry_loaded',
entry: {
id: entryData.id,
title: entryData.title,
type: entryData.typeHandle,
section: entryData.sectionHandle,
author: entryData.author.fullName,
categories: entryData.categories?.map(cat => cat.title) || [],
tags: entryData.tags?.map(tag => tag.title) || []
}
});
}
Custom Events in Data Layer
Form Interaction Events
{# Track form interactions #}
<form id="contact-form" method="post">
{{ csrfInput() }}
{{ actionInput('contact-form/send') }}
<input type="text" name="fromName" id="name" required>
<input type="email" name="fromEmail" id="email" required>
<textarea name="message[body]" id="message" required></textarea>
<button type="submit">Send</button>
</form>
<script>
var form = document.getElementById('contact-form');
// Track form start
['input', 'focus'].forEach(function(event) {
form.addEventListener(event, function() {
if (!form.dataset.started) {
window.dataLayer.push({
'event': 'form_start',
'form': {
'name': 'contact_form',
'location': '{{ craft.app.request.pathInfo }}'
}
});
form.dataset.started = 'true';
}
}, { once: true });
});
// Track form submission
form.addEventListener('submit', function(e) {
window.dataLayer.push({
'event': 'form_submit',
'form': {
'name': 'contact_form',
'location': '{{ craft.app.request.pathInfo }}'
}
});
});
</script>
Download Tracking Events
{# Track asset downloads #}
{% for asset in entry.downloads.all() %}
<a href="{{ asset.url }}"
download
'event': 'file_download',
'file': {
'name': '{{ asset.filename|e('js') }}',
'type': '{{ asset.extension }}',
'size': {{ asset.size }},
'title': '{{ asset.title|e('js') }}',
'entry_id': '{{ entry.id }}'
}
});">
Download {{ asset.title }}
</a>
{% endfor %}
Element API Integration
Expose data layer data via Element API:
// config/element-api.php
use craft\elements\Entry;
use craft\helpers\UrlHelper;
return [
'endpoints' => [
'api/entry/<entryId:\d+>/data-layer.json' => function($entryId) {
return [
'elementType' => Entry::class,
'criteria' => ['id' => $entryId],
'one' => true,
'transformer' => function(Entry $entry) {
$author = $entry->getAuthor();
return [
'entry' => [
'id' => $entry->id,
'title' => $entry->title,
'slug' => $entry->slug,
'uri' => $entry->uri,
'url' => $entry->url,
'type' => $entry->type->handle,
'section' => $entry->section->handle,
'postDate' => $entry->postDate->format('Y-m-d'),
'author' => [
'id' => $author->id,
'name' => $author->fullName,
],
],
];
},
];
},
],
];
Data Layer Debugging
Debug Mode
{% if craft.app.config.general.devMode %}
<script>
// Visual data layer inspector
window.dataLayer = window.dataLayer || [];
window.dataLayerInspector = {
log: function() {
console.group('GTM Data Layer');
console.table(window.dataLayer);
console.groupEnd();
},
watch: function() {
var originalPush = window.dataLayer.push;
window.dataLayer.push = function() {
console.log('Data Layer Push:', arguments[0]);
return originalPush.apply(window.dataLayer, arguments);
};
}
};
// Auto-watch in dev mode
window.dataLayerInspector.watch();
// Add debug button
document.addEventListener('DOMContentLoaded', function() {
var btn = document.createElement('button');
btn.textContent = 'Inspect Data Layer';
btn.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:9999;padding:10px;';
btn.onclick = window.dataLayerInspector.log;
document.body.appendChild(btn);
});
</script>
{% endif %}
Best Practices
1. Consistent Naming Conventions
Use snake_case for all data layer keys:
{
'event': 'custom_event',
'user_id': '123',
'entry_type': 'blog_post'
}
2. Data Validation
Ensure data exists before pushing:
{% if entry is defined and entry.id %}
{# Safe to push entry data #}
{% endif %}
3. Privacy Considerations
Hash or exclude sensitive information:
'user_email': '{{ currentUser.email|hash('sha256') }}' {# Hashed #}
'user_password': null {# Never include #}
4. Live Preview Exclusion
Don't push data during Live Preview:
{% if not craft.app.request.isLivePreview %}
{# Data layer code #}
{% endif %}
5. Performance
Initialize data layer once, update with events:
// Initialize once
window.dataLayer = [{...initialData}];
// Push events as they occur
window.dataLayer.push({event: 'user_action'});
Next Steps
- GTM Setup - Configure GTM container
- GA4 Event Tracking - Use data layer with GA4
- E-commerce Tracking - Implement Commerce tracking