Build a structured data layer for your Grav site to power Google Tag Manager tags and variables. This guide covers data layer architecture specific to Grav's flat-file structure.
Data Layer Overview
The data layer is a JavaScript object that stores information about your Grav pages, user interactions, and site state. GTM reads this data to populate variables and trigger tags.
Base Data Layer Structure
Page Load Data Layer
Initialize on every page load before GTM:
{# user/themes/your-theme/templates/partials/base.html.twig #}
<script>
window.dataLayer = window.dataLayer || [];
// Push page load data
window.dataLayer.push({
'event': 'page_load',
'page': {
'type': '{{ page.template() }}',
'title': '{{ page.title|e("js") }}',
'route': '{{ page.route() }}',
'url': '{{ page.url(true)|e("js") }}',
'language': '{{ grav.language.getActive ?: grav.config.site.default_lang }}',
'published': '{{ page.date|date("Y-m-d") }}',
'modified': '{{ page.modified|date("Y-m-d") }}'
},
'site': {
'name': '{{ site.title|e("js") }}',
'environment': '{{ grav.config.system.environment ?: "production" }}',
'version': '{{ constant("GRAV_VERSION") }}'
}
});
</script>
<!-- GTM code follows -->
Taxonomy Data
Include Grav's taxonomy information:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'page_load',
'page': {
'type': '{{ page.template() }}',
'title': '{{ page.title|e("js") }}',
'category': '{{ page.taxonomy.category|first ?: "uncategorized" }}',
'categories': {{ page.taxonomy.category|json_encode|raw }},
'tags': {{ page.taxonomy.tag|json_encode|raw }},
'tagString': '{{ page.taxonomy.tag|join(",") }}'
}
});
</script>
Page-Specific Data Layers
Blog Post Data Layer
{# templates/item.html.twig or blog.html.twig #}
{% if page.template() == 'item' or page.template() == 'blog' %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'blog_post_view',
'content': {
'type': 'blog_post',
'id': '{{ page.slug }}',
'title': '{{ page.title|e("js") }}',
'author': '{{ page.header.author ?: "unknown" }}',
'category': '{{ page.taxonomy.category|first ?: "general" }}',
'tags': {{ page.taxonomy.tag|json_encode|raw }},
'publishDate': '{{ page.date|date("Y-m-d") }}',
'wordCount': {{ page.content|striptags|split(' ')|length }},
'featured': {{ page.header.featured ? 'true' : 'false' }}
}
});
</script>
{% endif %}
Modular Page Data Layer
{# templates/modular.html.twig #}
{% set modules = [] %}
{% for module in page.collection() %}
{% set modules = modules|merge([module.template()]) %}
{% endfor %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'modular_page_view',
'page': {
'type': 'modular',
'title': '{{ page.title|e("js") }}',
'modules': {{ modules|json_encode|raw }},
'moduleCount': {{ modules|length }}
}
});
</script>
Form Page Data Layer
{# For pages with forms #}
{% if page.header.form %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'form_page_view',
'form': {
'name': '{{ page.header.form.name }}',
'type': '{{ page.template() }}',
'fieldCount': {{ page.header.form.fields|length }}
}
});
</script>
{% endif %}
User Data
Authenticated User Data
<script>
window.dataLayer = window.dataLayer || [];
var userData = {
'user': {
'status': '{{ grav.user.authenticated ? "logged_in" : "guest" }}'
}
};
{% if grav.user.authenticated %}
userData.user.id = '{{ grav.user.username|md5 }}'; // Hashed for privacy
userData.user.username = '{{ grav.user.username }}';
userData.user.email = '{{ grav.user.email|md5 }}'; // Hashed
userData.user.groups = {{ grav.user.groups|json_encode|raw }};
userData.user.access = {{ grav.user.access|json_encode|raw }};
userData.user.fullname = '{{ grav.user.fullname|e("js") }}';
{% endif %}
window.dataLayer.push(userData);
</script>
Event Data Layers
Form Submission Events
{# Track form submission #}
<form name="{{ form.name }}"
action="{{ form.action ?: page.route }}"
method="POST"
{{ form.fields|join|raw }}
<button type="submit">Submit</button>
</form>
<script>
function trackFormSubmit(form) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'form_submit',
'form': {
'name': form.name,
'action': '{{ form.action ?: page.route }}',
'page': '{{ page.title|e("js") }}',
'fields': {{ form.fields|keys|json_encode|raw }}
}
});
}
// Track successful submission
{% if grav.uri.query('sent') == 'true' %}
window.dataLayer.push({
'event': 'form_success',
'form': {
'name': '{{ form.name }}',
'type': 'contact'
}
});
{% endif %}
</script>
Download Tracking
<script>
document.addEventListener('DOMContentLoaded', function() {
// Track all download links
var downloadLinks = document.querySelectorAll('a[href$=".pdf"], a[href$=".zip"], a[href$=".doc"]');
downloadLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
var url = this.href;
var fileName = url.substring(url.lastIndexOf('/') + 1);
var fileType = fileName.substring(fileName.lastIndexOf('.') + 1);
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'file_download',
'file': {
'name': fileName,
'type': fileType,
'url': url,
'page': '{{ page.title|e("js") }}'
}
});
});
});
});
</script>
Scroll Tracking
<script>
(function() {
var scrollDepths = [25, 50, 75, 90];
var scrolledTo = [];
window.addEventListener('scroll', function() {
var scrollPercentage = Math.round((window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100);
scrollDepths.forEach(function(depth) {
if (scrollPercentage >= depth && scrolledTo.indexOf(depth) === -1) {
scrolledTo.push(depth);
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'scroll',
'scrollDepth': depth,
'page': {
'title': '{{ page.title|e("js") }}',
'type': '{{ page.template() }}'
}
});
}
});
}, { passive: true });
})();
</script>
Navigation Clicks
{# templates/partials/navigation.html.twig #}
<nav id="main-nav">
{% for item in pages.children.visible %}
<a href="{{ item.url }}" '{{ item.menu|e("js") }}', '{{ loop.index }}')">
{{ item.menu }}
</a>
{% endfor %}
</nav>
<script>
function trackNavClick(element, label, position) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'nav_click',
'navigation': {
'text': label,
'url': element.href,
'position': position,
'type': 'main_menu'
}
});
}
</script>
Custom Page Fields
Push custom frontmatter fields to data layer:
# pages/product.md
---
title: Premium Product
price: 99.99
product_id: 'PROD-001'
in_stock: true
---
{# templates/product.html.twig #}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'product_view',
'product': {
'id': '{{ page.header.product_id }}',
'name': '{{ page.title|e("js") }}',
'price': {{ page.header.price }},
'inStock': {{ page.header.in_stock ? 'true' : 'false' }},
'category': '{{ page.taxonomy.category|first }}'
}
});
</script>
Collection-Based Data
Track collection/listing pages:
{# templates/blog.html.twig - Blog listing page #}
{% set collection = page.collection() %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'collection_view',
'collection': {
'type': 'blog',
'itemCount': {{ collection|length }},
'page': {{ grav.uri.param('page') ?: 1 }},
'category': '{{ page.taxonomy.category|first ?: "all" }}'
}
});
</script>
Search Data Layer
Track SimpleSearch plugin searches:
{# templates/simplesearch_results.html.twig #}
{% if query %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'search',
'search': {
'term': '{{ query|e("js") }}',
'results': {{ results|length }},
'hasResults': {{ results|length > 0 ? 'true' : 'false' }}
}
});
</script>
{% endif %}
Multi-Language Data
Track language and translations:
{% set active_lang = grav.language.getActive ?: grav.config.site.default_lang %}
{% set available_langs = grav.language.getLanguages %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'language': {
'current': '{{ active_lang }}',
'available': {{ available_langs|json_encode|raw }},
'default': '{{ grav.config.site.default_lang }}'
}
});
</script>
Creating GTM Variables
Data Layer Variables
Create these variables in GTM to access your data:
Page Type
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
page.type - Name:
DLV - Page Type
Page Title
- Data Layer Variable Name:
page.title - Name:
DLV - Page Title
- Data Layer Variable Name:
Page Category
- Data Layer Variable Name:
page.category - Name:
DLV - Page Category
- Data Layer Variable Name:
Page Tags
- Data Layer Variable Name:
page.tagString - Name:
DLV - Page Tags
- Data Layer Variable Name:
User Status
- Data Layer Variable Name:
user.status - Name:
DLV - User Status
- Data Layer Variable Name:
Form Name
- Data Layer Variable Name:
form.name - Name:
DLV - Form Name
- Data Layer Variable Name:
Custom JavaScript Variables
Get All Page Tags as Array:
function() {
var dl = window.dataLayer || [];
for (var i = dl.length - 1; i >= 0; i--) {
if (dl[i].page && dl[i].page.tags) {
return dl[i].page.tags;
}
}
return [];
}
Get Content Age in Days:
function() {
var dl = window.dataLayer || [];
for (var i = dl.length - 1; i >= 0; i--) {
if (dl[i].page && dl[i].page.published) {
var published = new Date(dl[i].page.published);
var now = new Date();
return Math.floor((now - published) / (1000 * 60 * 60 * 24));
}
}
return null;
}
Creating GTM Triggers
Page View Trigger
- Trigger Type: Custom Event
- Event name:
page_load - Name:
CE - Page Load
Form Submit Trigger
- Trigger Type: Custom Event
- Event name:
form_submit - Name:
CE - Form Submit
Specific Page Type Trigger
- Trigger Type: Custom Event
- Event name:
page_load - Fire on: Some Custom Events
- Condition:
\{\{DLV - Page Type\}\}equalsblog - Name:
CE - Blog Page View
Helper Macros
Create reusable Twig macros for data layer:
{# templates/macros/datalayer.html.twig #}
{% macro push(data) %}
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({{ data|json_encode|raw }});
</script>
{% endmacro %}
{% macro event(eventName, eventData) %}
<script>
window.dataLayer = window.dataLayer || [];
var eventObject = { 'event': '{{ eventName }}' };
{% if eventData %}
Object.assign(eventObject, {{ eventData|json_encode|raw }});
{% endif %}
window.dataLayer.push(eventObject);
</script>
{% endmacro %}
Usage:
{% import 'macros/datalayer.html.twig' as dl %}
{{ dl.event('custom_event', {
'customField': 'value',
'page': page.title
}) }}
Best Practices
1. Initialize Before GTM
Always initialize data layer BEFORE GTM loads:
<!-- 1. Data Layer -->
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ /* data */ });
</script>
<!-- 2. Then GTM -->
<script>
(function(w,d,s,l,i){ /* GTM code */ })();
</script>
2. Use Consistent Naming
// Good - camelCase, consistent
{
'pageType': 'blog',
'pageTitle': 'My Post',
'userName': 'john'
}
// Bad - mixed styles
{
'page_type': 'blog',
'PageTitle': 'My Post',
'user-name': 'john'
}
3. Avoid PII
Never push personally identifiable information:
{# Bad #}
'userEmail': '{{ grav.user.email }}' {# Don't do this #}
{# Good - hashed #}
'userId': '{{ grav.user.email|md5 }}'
4. Clear Event Data
Clear event-specific data after pushing (optional):
// Push event
dataLayer.push({
'event': 'form_submit',
'form': { 'name': 'contact' }
});
// Clear (GTM handles automatically, but can do manually)
dataLayer.push({
'form': undefined
});
Debugging Data Layer
Console Commands
// View entire data layer
console.table(window.dataLayer);
// Find specific events
window.dataLayer.filter(obj => obj.event === 'page_load');
// Monitor new pushes
var originalPush = window.dataLayer.push;
window.dataLayer.push = function() {
console.log('DataLayer Push:', arguments[0]);
return originalPush.apply(window.dataLayer, arguments);
};
GTM Preview Mode
- Enable Preview in GTM
- Navigate to your Grav site
- Click Data Layer tab in Tag Assistant
- Verify all data populates correctly
Common Issues
Data Layer Not Populating
Issue: Variables return undefined in GTM.
Checks:
- Data layer pushed before GTM loads
- Variable names match exactly
- Data exists in Grav page/config
- No JavaScript errors
Duplicate Events
Issue: Events fire multiple times.
Fix: Ensure data layer code only executes once:
{% if not dl_initialized %}
{% set dl_initialized = true %}
<script>/* data layer code */</script>
{% endif %}
Next Steps
- Install GTM - Set up GTM container
- GA4 Events - Use data layer for GA4
- Troubleshoot Events - Debug tracking issues
For general data layer concepts, see Data Layer Guide.