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
Method 1: Custom Theme Integration (Recommended)
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:
contentTypeequalspost
Trigger: Member Content
- Type: Custom Event
- Event name:
ghost_data_ready - This trigger fires on: Some Custom Events
- Condition:
post.visibilityequalsmembers
Trigger: Paid Content
- Type: Custom Event
- Event name:
ghost_data_ready - This trigger fires on: Some Custom Events
- Condition:
post.visibilityequalspaid
Member Status Triggers
Trigger: Logged-In Members
- Type: Custom Event
- Event name:
ghost_data_ready - This trigger fires on: Some Custom Events
- Condition:
member.statusdoes not equalundefined
Trigger: Paid Members
- Type: Custom Event
- Event name:
ghost_data_ready - This trigger fires on: Some Custom Events
- Condition:
member.isPaidequalstrue
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
- In GTM, click Preview
- Enter your Ghost site URL
- Click Connect
- Verify
ghost_data_readyevent fires - Check Data Layer tab in Tag Assistant
- 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_readyevent fires - Variable Path Wrong - Check data layer variable name matches object path
- Case Sensitivity - JavaScript is case-sensitive:
post.Title≠post.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
- GA4 Event Tracking - Track events using GTM data layer
- Meta Pixel Setup - Use data layer for Meta events
- Troubleshooting Events - Debug data layer issues