Ghost Data Layer for Google Tag Manager | OpsBlu Docs

Ghost Data Layer for Google Tag Manager

Configure GTM data layer with Ghost @post, @site, and @member variables for enhanced tracking

A properly configured data layer enables GTM to access Ghost-specific information like post metadata, author details, member status, and subscription tiers. This guide shows how to build a comprehensive Ghost data layer using Handlebars helpers.

Ghost Data Layer Overview

The data layer is a JavaScript object that stores information about the current page, user, and context. GTM tags access this data via variables.

Ghost Data Categories

  • Site Data - Global Ghost settings (@site helper)
  • Content Data - Post/page metadata (@post helper)
  • Author Data - Author information (@author helper)
  • Member Data - Logged-in member context (@member helper)
  • Tag/Category Data - Content taxonomy (tags, primary_tag)
  • Visibility Data - Content access level (public, members, paid)

Basic Data Layer Implementation

Create partials/gtm-datalayer.hbs in your Ghost theme:

{{!-- partials/gtm-datalayer.hbs --}}
<script>
  // Initialize dataLayer before GTM loads
  window.dataLayer = window.dataLayer || [];

  // Push Ghost data to dataLayer
  dataLayer.push({
    'event': 'ghost_data_ready',

    // Site information
    'site': {
      'title': '{{@site.title}}',
      'description': '{{@site.description}}',
      'url': '{{@site.url}}',
      'locale': '{{@site.locale}}',
      {{#if @site.timezone}}
      'timezone': '{{@site.timezone}}',
      {{/if}}
      {{#if @site.members_enabled}}
      'membersEnabled': true,
      {{else}}
      'membersEnabled': false,
      {{/if}}
    },

    // Content type
    'contentType': '{{#is "post"}}post{{/is}}{{#is "page"}}page{{/is}}{{#is "home"}}home{{/is}}{{#is "tag"}}tag{{/is}}{{#is "author"}}author{{/is}}',

    {{#post}}
    // Post/Page data
    'post': {
      'id': '{{id}}',
      'uuid': '{{uuid}}',
      'title': '{{title}}',
      'slug': '{{slug}}',
      'excerpt': '{{excerpt}}',
      'featureImage': '{{feature_image}}',
      'featured': {{#if featured}}true{{else}}false{{/if}},
      'visibility': '{{visibility}}',
      'publishedAt': '{{published_at}}',
      'updatedAt': '{{updated_at}}',
      'customExcerpt': '{{custom_excerpt}}',
      'codeinjectionHead': {{#if codeinjection_head}}true{{else}}false{{/if}},
      'codeinjectionFoot': {{#if codeinjection_foot}}true{{else}}false{{/if}},
      'ogImage': '{{og_image}}',
      'ogTitle': '{{og_title}}',
      'ogDescription': '{{og_description}}',
      'twitterImage': '{{twitter_image}}',
      'twitterTitle': '{{twitter_title}}',
      'twitterDescription': '{{twitter_description}}',
      'metaTitle': '{{meta_title}}',
      'metaDescription': '{{meta_description}}',
      'url': '{{url absolute="true"}}',
    },

    // Author data
    'author': {
      'id': '{{primary_author.id}}',
      'slug': '{{primary_author.slug}}',
      'name': '{{primary_author.name}}',
      'bio': '{{primary_author.bio}}',
      'website': '{{primary_author.website}}',
      'location': '{{primary_author.location}}',
      'facebook': '{{primary_author.facebook}}',
      'twitter': '{{primary_author.twitter}}',
    },

    // Tags
    'tags': [
      {{#foreach tags}}
      {
        'id': '{{id}}',
        'name': '{{name}}',
        'slug': '{{slug}}',
        'description': '{{description}}'
      }{{#unless @last}},{{/unless}}
      {{/foreach}}
    ],

    {{#primary_tag}}
    // Primary tag
    'primaryTag': {
      'id': '{{id}}',
      'name': '{{name}}',
      'slug': '{{slug}}',
      'description': '{{description}}'
    },
    {{/primary_tag}}
    {{/post}}

    {{#page}}
    // Page-specific data
    'page': {
      'id': '{{id}}',
      'title': '{{title}}',
      'slug': '{{slug}}',
      'url': '{{url absolute="true"}}'
    },
    {{/page}}

    {{#member}}
    // Member data (logged-in users)
    'member': {
      'uuid': '{{uuid}}',
      'email': '{{email}}',
      'name': '{{name}}',
      'createdAt': '{{created_at}}',
      'status': '{{#if paid}}paid{{else}}free{{/if}}',
      'isPaid': {{#if paid}}true{{else}}false{{/if}},
      {{#if subscriptions}}
      'subscriptions': [
        {{#foreach subscriptions}}
        {
          'id': '{{id}}',
          'status': '{{status}}',
          'startDate': '{{start_date}}',
          'currentPeriodEnd': '{{current_period_end}}',
          'cancelAtPeriodEnd': {{#if cancel_at_period_end}}true{{else}}false{{/if}},
          'tier': {
            'id': '{{tier.id}}',
            'name': '{{tier.name}}',
            'slug': '{{tier.slug}}'
          }
        }{{#unless @last}},{{/unless}}
        {{/foreach}}
      ],
      {{/if}}
    },
    {{/member}}

    {{^member}}
    // Visitor (non-member)
    'visitor': {
      'status': 'anonymous'
    },
    {{/member}}

    // Page metadata
    'page': {
      'title': '{{meta_title}}',
      'description': '{{meta_description}}',
      'url': '{{@site.url}}{{url}}',
      'path': '{{url}}',
    }
  });
</script>

Include Data Layer in Theme

Edit default.hbs and add before the GTM container script:

<head>
    <meta charset="utf-8">
    <title>{{meta_title}}</title>

    {{!-- Ghost Data Layer (before GTM) --}}
    {{> gtm-datalayer}}

    {{!-- 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','GTM-XXXXXXX');</script>

    {{ghost_head}}
</head>

Method 2: Code Injection (Limited)

For Ghost(Pro) or when you can't modify themes, use code injection:

<!-- In Settings → Code Injection → Site Header -->
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    'event': 'ghost_data_ready',
    'contentType': '{{#is "post"}}post{{/is}}{{#is "page"}}page{{/is}}{{#is "home"}}home{{/is}}',
    {{#post}}
    'postId': '{{id}}',
    'postTitle': '{{title}}',
    'postAuthor': '{{primary_author.name}}',
    'postVisibility': '{{visibility}}',
    {{/post}}
    {{#member}}
    'memberStatus': 'member',
    'memberPaid': {{#if paid}}true{{else}}false{{/if}},
    {{/member}}
    {{^member}}
    'memberStatus': 'visitor',
    {{/member}}
  });
</script>

Note: Code injection has limited Handlebars support. Use custom theme for full data layer.

GTM Variable Configuration

Once the data layer is in place, create GTM variables to access the data.

Create Data Layer Variables

In GTM, navigate to Variables → New → User-Defined Variables:

Site Variables

Variable Name Variable Type Data Layer Variable Name
Site Title Data Layer Variable site.title
Site URL Data Layer Variable site.url
Members Enabled Data Layer Variable site.membersEnabled

Content Variables

Variable Name Variable Type Data Layer Variable Name
Content Type Data Layer Variable contentType
Post ID Data Layer Variable post.id
Post Title Data Layer Variable post.title
Post Slug Data Layer Variable post.slug
Post Visibility Data Layer Variable post.visibility
Post URL Data Layer Variable post.url
Post Published Date Data Layer Variable post.publishedAt

Author Variables

Variable Name Variable Type Data Layer Variable Name
Author Name Data Layer Variable author.name
Author Slug Data Layer Variable author.slug
Author ID Data Layer Variable author.id

Member Variables

Variable Name Variable Type Data Layer Variable Name
Member Status Data Layer Variable member.status
Member UUID Data Layer Variable member.uuid
Member Email Data Layer Variable member.email
Member Is Paid Data Layer Variable member.isPaid

Tag Variables

Variable Name Variable Type Data Layer Variable Name
Primary Tag Name Data Layer Variable primaryTag.name
Primary Tag Slug Data Layer Variable primaryTag.slug

Built-In GTM Variables

Enable these in Variables → Configure:

  • Page URL
  • Page Hostname
  • Page Path
  • Referrer
  • Click Element
  • Click Classes
  • Click ID
  • Click Text
  • Form Element
  • Form Classes
  • Form ID

Advanced Data Layer Patterns

Conditional Data Based on Context

<script>
  window.dataLayer = window.dataLayer || [];

  var ghostData = {
    'event': 'ghost_data_ready',
    'contentType': '{{#is "post"}}post{{/is}}{{#is "page"}}page{{/is}}{{#is "home"}}home{{/is}}'
  };

  {{#post}}
  // Only push post data if on a post
  ghostData.post = {
    'id': '{{id}}',
    'title': '{{title}}',
    'visibility': '{{visibility}}',

    // Custom visibility flags
    'isPublic': {{#has visibility="public"}}true{{else}}false{{/has}},
    'isMembersOnly': {{#has visibility="members"}}true{{else}}false{{/has}},
    'isPaidOnly': {{#has visibility="paid"}}true{{else}}false{{/has}},
  };
  {{/post}}

  {{#member}}
  // Only push member data if logged in
  ghostData.member = {
    'status': '{{#if paid}}paid{{else}}free{{/if}}',
    'uuid': '{{uuid}}',

    {{#if subscriptions}}
    'activeTier': '{{subscriptions.[0].tier.name}}',
    'subscriptionStatus': '{{subscriptions.[0].status}}',
    {{/if}}
  };
  {{/member}}

  dataLayer.push(ghostData);
</script>

E-commerce Data Layer

For Ghost membership tracking:

<script>
  {{#if @member.subscriptions}}
  // Push e-commerce data for paid members
  dataLayer.push({
    'event': 'member_subscription_active',
    'ecommerce': {
      'currencyCode': '{{@member.subscriptions.[0].tier.currency}}',
      'purchase': {
        'actionField': {
          'id': '{{@member.subscriptions.[0].id}}',
          'revenue': '{{@member.subscriptions.[0].tier.monthly_price}}',
        },
        'products': [{
          'id': '{{@member.subscriptions.[0].tier.id}}',
          'name': '{{@member.subscriptions.[0].tier.name}}',
          'category': 'Membership',
          'variant': '{{@member.subscriptions.[0].cadence}}',
          'price': '{{@member.subscriptions.[0].tier.monthly_price}}',
          'quantity': 1
        }]
      }
    }
  });
  {{/if}}
</script>

User Journey Tracking

<script>
  dataLayer.push({
    'event': 'user_journey',
    'userType': '{{#member}}member{{else}}visitor{{/member}}',

    {{#member}}
    'memberJourneyStage': '{{#if paid}}paid_subscriber{{else}}free_member{{/if}}',
    'daysSinceMemberJoin': Math.floor((new Date() - new Date('{{created_at}}')) / (1000 * 60 * 60 * 24)),
    {{/member}}

    {{^member}}
    'visitorStatus': 'anonymous',
    {{/member}}

    {{#post}}
    'contentAccess': '{{#has visibility="public"}}public{{/has}}{{#has visibility="members"}}members_only{{/has}}{{#has visibility="paid"}}paid_only{{/has}}'
    {{/post}}
  });
</script>

Custom Event Tracking

Portal Events

<script>
  // Listen for Ghost Portal events and push to dataLayer
  window.addEventListener('portal-open', function() {
    dataLayer.push({
      'event': 'ghost_portal_opened',
      {{#member}}
      'memberStatus': 'logged_in',
      {{/member}}
      {{^member}}
      'memberStatus': 'anonymous',
      {{/member}}
    });
  });

  window.addEventListener('portal-signup', function() {
    dataLayer.push({
      'event': 'ghost_member_signup',
      'signupMethod': 'portal'
    });
  });

  window.addEventListener('portal-signin', function() {
    dataLayer.push({
      'event': 'ghost_member_signin'
    });
  });

  window.addEventListener('portal-signout', function() {
    dataLayer.push({
      'event': 'ghost_member_signout'
    });
  });
</script>

Content Interaction Events

<script>
  {{#post}}
  // Reading progress
  var milestones = [25, 50, 75, 100];
  var tracked = [];

  function trackProgress() {
    var articleHeight = document.querySelector('article').offsetHeight;
    var windowHeight = window.innerHeight;
    var scrollTop = window.pageYOffset;
    var percent = ((scrollTop + windowHeight) / articleHeight) * 100;

    milestones.forEach(function(milestone) {
      if (percent >= milestone && tracked.indexOf(milestone) === -1) {
        tracked.push(milestone);
        dataLayer.push({
          'event': 'reading_progress',
          'scrollDepth': milestone,
          'postId': '{{id}}',
          'postTitle': '{{title}}'
        });
      }
    });
  }

  window.addEventListener('scroll', function() {
    setTimeout(trackProgress, 100);
  });
  {{/post}}
</script>

GTM Trigger Configuration

Content-Type Triggers

Trigger: Blog Posts Only

  • Type: Custom Event
  • Event name: ghost_data_ready
  • This trigger fires on: Some Custom Events
  • Condition: contentType equals post

Trigger: Member Content

  • Type: Custom Event
  • Event name: ghost_data_ready
  • This trigger fires on: Some Custom Events
  • Condition: post.visibility equals members

Trigger: Paid Content

  • Type: Custom Event
  • Event name: ghost_data_ready
  • This trigger fires on: Some Custom Events
  • Condition: post.visibility equals paid

Member Status Triggers

Trigger: Logged-In Members

  • Type: Custom Event
  • Event name: ghost_data_ready
  • This trigger fires on: Some Custom Events
  • Condition: member.status does not equal undefined

Trigger: Paid Members

  • Type: Custom Event
  • Event name: ghost_data_ready
  • This trigger fires on: Some Custom Events
  • Condition: member.isPaid equals true

Using Data Layer in GTM Tags

GA4 Configuration with Ghost Data

Tag: GA4 Configuration

  • Tag Type: Google Analytics: GA4 Configuration
  • Measurement ID: G-XXXXXXXXXX
  • Configuration Parameters:
    • content_type: \{\{Content Type\}\}
    • author_name: \{\{Author Name\}\}
    • post_visibility: \{\{Post Visibility\}\}
    • member_status: \{\{Member Status\}\}

Custom HTML Tag with Data Layer

<!-- Custom HTML Tag Example -->
<script>
  console.log('Content Type:', {{Content Type}});
  console.log('Post Title:', {{Post Title}});
  console.log('Member Status:', {{Member Status}});

  // Send custom event to analytics
  if (typeof gtag !== 'undefined') {
    gtag('event', 'ghost_page_view', {
      'content_type': {{Content Type}},
      'post_id': {{Post ID}},
      'author': {{Author Name}},
      'member_status': {{Member Status}}
    });
  }
</script>

Testing and Debugging

Preview Mode

  1. In GTM, click Preview
  2. Enter your Ghost site URL
  3. Click Connect
  4. Verify ghost_data_ready event fires
  5. Check Data Layer tab in Tag Assistant
  6. Verify all variables populate correctly

Console Testing

// In browser console on your Ghost site
console.log(dataLayer);

// Expected output: Array with Ghost data objects
// [{ event: 'ghost_data_ready', site: {...}, post: {...}, ... }]

// Check specific values
console.log(dataLayer[0].post.title);
console.log(dataLayer[0].member.status);

GTM Debug Mode

Enable GTM debug logging:

<!-- Add to GTM container script -->
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({'gtm.debug': true});
</script>

Performance Considerations

Minimize Data Layer Size

Only include necessary data:

<script>
  // Bad: Too much data
  dataLayer.push({
    'post': {
      'allContent': '{{content}}',  // Don't include full post content
      'allHTML': '{{{post.html}}}'  // Don't include rendered HTML
    }
  });

  // Good: Only metadata
  dataLayer.push({
    'post': {
      'id': '{{id}}',
      'title': '{{title}}',
      'excerpt': '{{excerpt}}'  // Short excerpt only
    }
  });
</script>

Lazy Load Non-Critical Data

<script>
  // Push critical data immediately
  dataLayer.push({
    'event': 'ghost_data_ready',
    'contentType': '{{#is "post"}}post{{/is}}'
  });

  // Lazy load member data after page load
  window.addEventListener('load', function() {
    {{#member}}
    dataLayer.push({
      'event': 'member_data_ready',
      'member': {
        'status': '{{#if paid}}paid{{else}}free{{/if}}'
      }
    });
    {{/member}}
  });
</script>

Common Issues

Data Layer Undefined

  • Load Order - Data layer must be defined before GTM script
  • Syntax Errors - Check for missing commas or brackets in data layer object
  • Handlebars Issues - Verify helpers like \{\{#post\}\} work in your theme

Variables Not Populating

  • Event Not Firing - Ensure ghost_data_ready event fires
  • Variable Path Wrong - Check data layer variable name matches object path
  • Case Sensitivity - JavaScript is case-sensitive: post.Titlepost.title

Member Data Missing

  • Cache Issues - Ghost caches pages; member context may be stale
  • Code Injection - Handlebars helpers limited in code injection (use theme)
  • Not Logged In - Verify testing with actual logged-in member

Performance Issues

  • Too Much Data - Minimize data layer size
  • Synchronous Execution - Ensure data layer doesn't block page render
  • Multiple Pushes - Consolidate data layer pushes

Next Steps