Ghost Analytics Implementation Guide | OpsBlu Docs

Ghost Analytics Implementation Guide

Complete guide to implementing analytics on Ghost CMS. Covers site-wide code injection, Handlebars helpers, Ghost Members tracking, headless Ghost with...

Analytics Architecture on Ghost

Ghost is a Node.js publishing platform that renders pages server-side using Handlebars templates. Each page load is a full server-rendered HTML response -- there is no client-side routing in the default theme system. This means standard analytics scripts fire naturally on every page navigation without requiring virtual page view workarounds.

Ghost provides three script injection mechanisms:

Site-wide Code Injection is configured in Ghost Admin under Settings > Code injection. The "Site Header" field injects code into every page's <head> section via the {{ghost_head}} Handlebars helper. The "Site Footer" field injects before the closing </body> tag via {{ghost_foot}}. This is the primary method for installing GTM, GA4, and consent management platforms.

Per-post Code Injection is available in the post editor under the post settings sidebar (click the gear icon). Each post can have its own header and footer code that runs only on that post. This is useful for per-article experiment scripts or conversion pixels on specific content.

Theme Template Editing gives full control through Handlebars .hbs files. The default.hbs layout file wraps every page. Individual templates like post.hbs, page.hbs, tag.hbs, and index.hbs control specific page types. Custom templates can be created by naming files page-{slug}.hbs or custom-{name}.hbs.

Ghost Members is the built-in membership and subscription system. Member state (free, paid, comped) is available in Handlebars templates via the {{#has}} helper and in JavaScript via the window.__GHOST_MEMBERS_DATA__ object, which enables segmenting analytics by membership tier.

Headless Ghost uses the Content API to serve content to a separate frontend (Next.js, Gatsby, Nuxt, Astro). In headless mode, Ghost themes are not used, and all script injection happens in the frontend framework. The Content API returns JSON, and you are responsible for rendering and tracking.


Installing Tracking Scripts

Site-Wide Code Injection

Navigate to Ghost Admin > Settings > Code injection. Place your GTM container snippet in the Site Header field:

<!-- Ghost Admin > Settings > Code injection > Site Header -->
<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>

Add the noscript fallback in the Site Footer field:

<!-- Ghost Admin > Settings > Code injection > Site Footer -->
<noscript>
  <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
    height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>

Theme-Level Script Injection

For more control over script placement or conditional loading, edit your theme's default.hbs. The {{ghost_head}} and {{ghost_foot}} helpers output Ghost's injected code plus meta tags:

{{!-- default.hbs --}}
<!DOCTYPE html>
<html lang="{{@site.lang}}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {{ghost_head}}

    {{!-- Custom tracking code loaded after ghost_head --}}
    <script>
      window.dataLayer = window.dataLayer || [];
    </script>
</head>
<body class="{{body_class}}">
    {{{body}}}

    {{ghost_foot}}
</body>
</html>

Per-Post Code Injection

Open any post in the Ghost editor, click the gear icon in the top-right to open post settings, and scroll to Code injection. Enter header or footer code that runs only on that specific post:

<!-- Post Settings > Code injection > Post Header -->
<script>
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    'event': 'experiment_active',
    'experiment_id': 'headline_test_042',
    'experiment_variant': 'b'
  });
</script>

Data Layer Setup

Ghost does not have a built-in data layer, but its Handlebars template system provides direct access to all post, author, tag, and site metadata at render time. This makes server-rendered data layer pushes straightforward and reliable.

Post Metadata Data Layer

Add this script to your post.hbs template (or in the site footer code injection if you cannot modify the theme):

{{!-- post.hbs: data layer push with post metadata --}}
<script>
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    'event': 'post_view',
    'content_type': 'post',
    'post_id': '{{id}}',
    'post_title': '{{title}}',
    'post_slug': '{{slug}}',
    'post_published_at': '{{date published_at format="YYYY-MM-DD"}}',
    'post_updated_at': '{{date updated_at format="YYYY-MM-DD"}}',
    'post_reading_time': {{reading_time}},
    'post_primary_tag': '{{#primary_tag}}{{name}}{{/primary_tag}}',
    'post_primary_author': '{{#primary_author}}{{name}}{{/primary_author}}',
    'post_tags': [{{#foreach tags}}'{{name}}'{{#unless @last}}, {{/unless}}{{/foreach}}],
    'post_featured': {{#if featured}}true{{else}}false{{/if}},
    'post_visibility': '{{visibility}}'
  });
</script>

Page Type Detection

For a site-wide data layer that identifies page type regardless of content, add to default.hbs before {{ghost_foot}}:

{{!-- default.hbs: global page type data layer --}}
<script>
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    'page_type': '{{#is "post"}}post{{/is}}{{#is "page"}}page{{/is}}{{#is "home"}}home{{/is}}{{#is "tag"}}tag{{/is}}{{#is "author"}}author{{/is}}{{#is "paged"}}archive{{/is}}',
    'site_title': '{{@site.title}}',
    'site_language': '{{@site.lang}}'
  });
</script>

Ghost Members Data Layer

Ghost Members injects membership data into the page. You can read the member state and push it to the data layer to segment analytics by membership tier:

<!-- Site Footer Code Injection: member context -->
<script>
  document.addEventListener('DOMContentLoaded', function() {
    window.dataLayer = window.dataLayer || [];

    // Ghost exposes member data on the page
    var memberData = {
      'event': 'member_context',
      'member_logged_in': false,
      'member_status': 'anonymous',
      'member_plan': 'none'
    };

    // Check for Ghost member portal data
    if (typeof window.__GHOST_MEMBERS_DATA__ !== 'undefined') {
      var ghost = window.__GHOST_MEMBERS_DATA__;
      if (ghost && ghost.uuid) {
        memberData['member_logged_in'] = true;
        memberData['member_status'] = ghost.status || 'free';
        memberData['member_plan'] = ghost.subscriptions && ghost.subscriptions.length > 0
          ? ghost.subscriptions[0].plan.nickname : 'free';
      }
    }

    window.dataLayer.push(memberData);
  });
</script>

Handlebars Conditional Tracking

Ghost's {{#has}} and {{#is}} helpers let you conditionally include tracking code based on content attributes directly in the template:

{{!-- Only track posts tagged "sponsored" --}}
{{#has tag="sponsored"}}
<script>
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    'event': 'sponsored_content_view',
    'sponsor': '{{#primary_tag}}{{name}}{{/primary_tag}}',
    'post_slug': '{{slug}}'
  });
</script>
{{/has}}

Headless Ghost: SPA Route Change Tracking

When using Ghost as a headless CMS with a frontend framework like Next.js, Gatsby, or Nuxt, you lose the server-rendered theme system. The frontend handles all rendering and routing, which means you need SPA-style route change tracking.

Next.js with Ghost Content API

// pages/_app.js or app/layout.js (Next.js + Ghost headless)
import { useEffect } from 'react';
import { useRouter } from 'next/router';

export default function App({ Component, pageProps }) {
  const router = useRouter();

  useEffect(() => {
    const handleRouteChange = (url) => {
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({
        'event': 'virtual_page_view',
        'page_path': url,
        'page_title': document.title,
        'content_source': 'ghost_content_api'
      });
    };

    router.events.on('routeChangeComplete', handleRouteChange);
    return () => {
      router.events.off('routeChangeComplete', handleRouteChange);
    };
  }, [router]);

  return <Component {...pageProps} />;
}

Pushing Ghost Content API Metadata in Headless Mode

When fetching content from the Ghost Content API, include metadata in your data layer push:

// Example: fetching a Ghost post and pushing metadata
import GhostContentAPI from '@tryghost/content-api';

const ghost = new GhostContentAPI({
  url: 'https://your-ghost-site.com',
  key: 'your-content-api-key',
  version: 'v5.0'
});

export async function getPostAndTrack(slug) {
  const post = await ghost.posts.read({ slug }, { include: 'tags,authors' });

  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    'event': 'ghost_post_view',
    'post_id': post.id,
    'post_title': post.title,
    'post_slug': post.slug,
    'post_reading_time': post.reading_time,
    'post_primary_tag': post.primary_tag ? post.primary_tag.name : null,
    'post_primary_author': post.primary_author ? post.primary_author.name : null,
    'post_published_at': post.published_at,
    'post_visibility': post.visibility
  });

  return post;
}

Common Errors

Error Cause Fix
Code injection scripts do not appear on the page The active theme's default.hbs is missing {{ghost_head}} or {{ghost_foot}} helpers Verify that default.hbs includes both {{ghost_head}} in <head> and {{ghost_foot}} before </body>
Data layer values render as literal Handlebars syntax Handlebars expressions placed inside code injection (admin) instead of theme .hbs files Handlebars helpers like {{title}} and {{slug}} only work in theme template files, not in the admin code injection fields; move the script to post.hbs
GTM fires on blog posts but not on static pages The page.hbs template has a different layout that does not include {{ghost_head}} Ensure page.hbs extends default.hbs or includes its own {{ghost_head}} and {{ghost_foot}} calls
Ghost Members data is undefined in the data layer The script runs before Ghost Portal initializes the member context Wrap the member detection code in a DOMContentLoaded listener or use a setTimeout delay of 500ms as a fallback
Per-post code injection does not execute Ghost caches rendered HTML aggressively; the code injection change has not propagated Clear the Ghost content cache by restarting Ghost or toggling the post to draft and back to published
Duplicate page views in GA4 Both a hardcoded GA4 snippet in code injection and a GTM GA4 tag fire page views Remove the standalone GA4 snippet from code injection if you are using GTM to manage GA4
Tag and author archive pages have no data layer Data layer scripts in post.hbs do not run on tag.hbs or author.hbs templates Add page-type-appropriate data layer pushes to tag.hbs, author.hbs, and index.hbs separately
Headless Ghost frontend sends no analytics events The frontend framework was not configured with route change tracking Implement a route change listener in your frontend framework that pushes virtual page views to the data layer
Reading time shows 0 in the data layer The {{reading_time}} helper returns a string like 3 min read, not a number Use a regex or parse the integer: parseInt('{{reading_time}}'.match(/\\d+/)[0], 10)
AMP pages bypass all custom tracking Ghost AMP templates do not load standard JavaScript Use amp-analytics components in Ghost's amp.hbs template or disable AMP via routes.yaml and redirect AMP URLs to canonical

Performance Considerations

  • Ghost's Node.js server renders HTML fast, typically under 50ms. Adding synchronous scripts in the Site Header code injection delays first contentful paint. Load GTM with async and defer social pixels to the footer injection point.

  • Avoid multiple code injection scripts that each query the DOM independently. Consolidate into one footer script that builds the full data layer object in a single pass rather than five separate dataLayer.push() calls that each run document.querySelector.

  • Ghost includes built-in structured data (JSON-LD) in {{ghost_head}}. Do not duplicate this with a separate structured data script. If you need to customize it, override Ghost's default by editing the config or using a custom theme partial.

  • Ghost's image processing generates responsive srcset attributes automatically. Third-party lazy-load libraries can conflict with Ghost's native image handling and cause LCP regressions. Test before adding libraries like lazysizes.

  • Member-gated content triggers an additional authentication check. If you track member state in the data layer, ensure the member detection script does not block rendering. Use an async pattern or requestIdleCallback for member data layer pushes.

  • Ghost(Pro) and self-hosted Ghost behave identically for analytics. The only difference is that Ghost(Pro) manages caching at the CDN level. After changing code injection, allow up to 60 seconds for CDN cache invalidation on Ghost(Pro).