Craft CMS Data Layer for GTM | OpsBlu Docs

Craft CMS Data Layer for GTM

Implement a comprehensive GTM data layer using Craft CMS Twig templates, including entry data, user information, e-commerce, and custom variables.

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

Resources