Data Layer Structure for Grav + GTM | OpsBlu Docs

Data Layer Structure for Grav + GTM

Complete reference for implementing a custom data layer for Grav flat-file CMS content with Google Tag Manager using Twig templates.

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>
{# 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:

  1. Page Type

    • Variable Type: Data Layer Variable
    • Data Layer Variable Name: page.type
    • Name: DLV - Page Type
  2. Page Title

    • Data Layer Variable Name: page.title
    • Name: DLV - Page Title
  3. Page Category

    • Data Layer Variable Name: page.category
    • Name: DLV - Page Category
  4. Page Tags

    • Data Layer Variable Name: page.tagString
    • Name: DLV - Page Tags
  5. User Status

    • Data Layer Variable Name: user.status
    • Name: DLV - User Status
  6. Form Name

    • Data Layer Variable Name: form.name
    • Name: DLV - Form 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

  1. Trigger Type: Custom Event
  2. Event name: page_load
  3. Name: CE - Page Load

Form Submit Trigger

  1. Trigger Type: Custom Event
  2. Event name: form_submit
  3. Name: CE - Form Submit

Specific Page Type Trigger

  1. Trigger Type: Custom Event
  2. Event name: page_load
  3. Fire on: Some Custom Events
  4. Condition: \{\{DLV - Page Type\}\} equals blog
  5. 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

  1. Enable Preview in GTM
  2. Navigate to your Grav site
  3. Click Data Layer tab in Tag Assistant
  4. 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

For general data layer concepts, see Data Layer Guide.