Sanity Google Tag Manager Integration | Blue Frog Docs

Sanity Google Tag Manager Integration

Integrate Google Tag Manager with Sanity-powered sites for flexible tag management and advanced tracking.

Sanity Google Tag Manager Integration

Complete guide to implementing Google Tag Manager (GTM) on your Sanity-powered website for centralized tag management, marketing pixels, and advanced analytics tracking.

Getting Started

Choose the implementation approach that best fits your frontend framework:

GTM Setup Guide

Step-by-step instructions for installing GTM on Sanity-powered sites using Next.js, Gatsby, Remix, and other frameworks. Includes container configuration and environment setup.

Data Layer Configuration

Build a robust data layer using GROQ queries to pass Sanity content metadata to GTM tags. Includes content tracking, user properties, and custom dimensions.

Why GTM for Sanity?

GTM provides powerful tag management capabilities for headless CMS implementations:

  • Centralized Tag Management: Update tracking without code deployments
  • Multiple Platform Support: Manage GA4, Meta Pixel, LinkedIn, TikTok, and more from one interface
  • GROQ-Powered Data Layer: Enrich tags with Sanity content metadata
  • Framework Compatibility: Works seamlessly with Next.js, Gatsby, Remix, SvelteKit
  • Version Control: Track changes and roll back tag configurations
  • Environment Support: Separate containers for development, staging, production
  • Custom Event Tracking: Fire tags based on Sanity content interactions

Implementation Options

Method Best For Complexity Framework
Next.js Script Component Next.js sites (App/Pages Router) Simple Next.js
Gatsby Plugin Static Gatsby sites Simple Gatsby
Custom Implementation Full control, complex requirements Moderate All frameworks
Server-Side GTM Privacy-focused, tag loading control Advanced All frameworks (SSR)
Partytown Integration Performance-critical sites Advanced Next.js, Astro

Prerequisites

Before starting:

  1. Google Tag Manager account created
  2. GTM container ID (format: GTM-XXXXXXX)
  3. Sanity project with GROQ API access
  4. Frontend framework deployed
  5. Understanding of your content schema and tracking needs

GTM Architecture for Sanity Sites

Data Flow

Sanity Content Lake
        ↓
    GROQ Query
        ↓
Frontend Component (fetches content metadata)
        ↓
Data Layer Push (window.dataLayer)
        ↓
GTM Container (processes data layer)
        ↓
Tags Fire (GA4, Meta Pixel, etc.)

Component Placement

Container Script:

  • Load in <head> for immediate availability
  • Use framework-specific optimizations (Next.js Script component)
  • Implement noscript fallback in <body>

Data Layer Initialization:

  • Initialize before GTM container loads
  • Set default values for content properties
  • Handle SSR/SSG hydration correctly

Event Triggers:

  • Fire on client-side interactions
  • Track navigation in SPA frameworks
  • Monitor content engagement

Sanity-Specific GTM Features

Content Metadata in Data Layer

Push Sanity document data to GTM:

// Fetch content with GROQ
const content = await client.fetch(`
  *[_type == "article" && slug.current == $slug][0]{
    _id,
    _type,
    _rev,
    title,
    "slug": slug.current,
    publishedAt,
    "author": author->name,
    "categories": categories[]->title,
    "tags": tags[]->name,
    "readingTime": round(length(pt::text(body)) / 5 / 180)
  }
`, { slug })

// Push to data layer
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
  event: 'contentView',
  content: {
    id: content._id,
    type: content._type,
    revision: content._rev,
    title: content.title,
    slug: content.slug,
    author: content.author,
    categories: content.categories,
    tags: content.tags,
    readingTimeMinutes: content.readingTime,
    publishedDate: content.publishedAt
  }
})

Document Type Tracking

Track different Sanity content types:

// Generic content tracking function
function trackSanityDocument(document) {
  window.dataLayer.push({
    event: 'sanityContentView',
    contentType: document._type,
    contentId: document._id,
    contentRevision: document._rev,
    customData: extractCustomData(document)
  })
}

// Type-specific tracking
function extractCustomData(document) {
  switch (document._type) {
    case 'product':
      return {
        price: document.price,
        inStock: document.inventory > 0,
        brand: document.brand
      }
    case 'article':
      return {
        author: document.author?.name,
        category: document.category?.title,
        wordCount: document.wordCount
      }
    case 'event':
      return {
        eventDate: document.eventDate,
        location: document.location?.name,
        ticketsAvailable: document.ticketsRemaining > 0
      }
    default:
      return {}
  }
}

Portable Text Engagement

Track interaction with rich content:

// Track scrolling through Portable Text blocks
const blocks = document.querySelectorAll('[data-portable-text-block]')

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      window.dataLayer.push({
        event: 'contentBlockView',
        blockKey: entry.target.dataset.portableTextBlock,
        blockType: entry.target.dataset.blockType,
        contentId: document._id
      })
    }
  })
}, { threshold: 0.5 })

blocks.forEach(block => observer.observe(block))

Real-Time Content Updates

Track content changes in preview mode:

// Sanity real-time listener
import { client } from './sanity'

client.listen('*[_type == "article"]').subscribe(update => {
  if (update.transition === 'update') {
    window.dataLayer.push({
      event: 'contentUpdated',
      documentId: update.documentId,
      transition: update.transition,
      timestamp: new Date().toISOString()
    })
  }
})

Multi-Language Support

For internationalized Sanity projects:

// Track content language
const localizedContent = await client.fetch(`
  *[_type == "post" && slug.current == $slug][0]{
    _id,
    __i18n_lang,
    __i18n_refs,
    __i18n_base
  }
`)

window.dataLayer.push({
  event: 'pageView',
  contentLanguage: localizedContent.__i18n_lang || 'en',
  contentId: localizedContent._id,
  hasTranslations: !!localizedContent.__i18n_refs,
  isTranslation: !!localizedContent.__i18n_base
})

Framework-Specific Examples

Next.js App Router

// app/layout.tsx
import { GoogleTagManager } from '@next/third-parties/google'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <GoogleTagManager gtmId="GTM-XXXXXXX" />
      </body>
    </html>
  )
}

Next.js Pages Router

// pages/_app.js
import Script from 'next/script'

export default function App({ Component, pageProps }) {
  return (
    <>
      <Script id="gtm-script" strategy="afterInteractive">
        {`
          (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>
      <noscript>
        <iframe
          src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
          height="0"
          width="0"
          style={{ display: 'none', visibility: 'hidden' }}
        />
      </noscript>
      <Component {...pageProps} />
    </>
  )
}

Gatsby

// gatsby-config.js
module.exports = {
  plugins: [
    {
      resolve: 'gatsby-plugin-google-tagmanager',
      options: {
        id: 'GTM-XXXXXXX',
        includeInDevelopment: false,
        defaultDataLayer: { platform: 'gatsby' },
        enableWebVitalsTracking: true,
      },
    },
  ],
}

Remix

// app/root.tsx
export default function Root() {
  return (
    <html>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (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');
            `
          }}
        />
      </head>
      <body>
        <noscript>
          <iframe
            src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
            height="0"
            width="0"
            style={{ display: 'none', visibility: 'hidden' }}
          />
        </noscript>
        <Outlet />
      </body>
    </html>
  )
}

SvelteKit

<!-- src/routes/+layout.svelte -->
<svelte:head>
  <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>
</svelte:head>

<noscript>
  <iframe
    src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
    height="0"
    width="0"
    title="Google Tag Manager"
    style="display:none;visibility:hidden"
  ></iframe>
</noscript>

<slot />

Data Layer Best Practices

Initialize Before GTM

// Always initialize data layer first
window.dataLayer = window.dataLayer || []

// Set initial state
window.dataLayer.push({
  platform: 'sanity',
  framework: 'nextjs',
  environment: process.env.NODE_ENV
})

// Then load GTM container

Use Consistent Naming

// Good: Consistent, descriptive names
window.dataLayer.push({
  event: 'content_view',
  content_type: 'article',
  content_id: '123',
  content_category: 'technology'
})

// Bad: Inconsistent naming
window.dataLayer.push({
  event: 'ContentView',
  contentType: 'article',
  id: '123',
  cat: 'technology'
})

Handle Missing Data Gracefully

// Safe data layer push with fallbacks
function safeDataLayerPush(content) {
  window.dataLayer.push({
    event: 'contentView',
    content: {
      id: content?._id || 'unknown',
      type: content?._type || 'unknown',
      title: content?.title || 'Untitled',
      author: content?.author?.name || 'Unknown',
      categories: content?.categories || []
    }
  })
}

Clear Sensitive Data

// Don't push PII to data layer
function sanitizeContent(content) {
  const {
    userEmail,
    userPhone,
    personalDetails,
    ...safeContent
  } = content

  return safeContent
}

window.dataLayer.push({
  event: 'contentView',
  content: sanitizeContent(content)
})

Performance Optimization

Defer GTM Loading

// Next.js - load GTM after interactive
<Script
  id="gtm-script"
  strategy="afterInteractive"  // or "lazyOnload"
>
  {/* GTM code */}
</Script>

Use Partytown for Web Workers

// Offload GTM to web worker (Next.js + Partytown)
import { Partytown } from '@builder.io/partytown/react'

export default function App({ Component, pageProps }) {
  return (
    <>
      <Partytown debug={true} forward={['dataLayer.push']} />
      <Script
        type="text/partytown"
        dangerouslySetInnerHTML={{
          __html: `/* GTM code */`
        }}
      />
      <Component {...pageProps} />
    </>
  )
}

Batch Data Layer Events

// Collect events and push in batches
const eventQueue = []

function queueDataLayerEvent(eventData) {
  eventQueue.push(eventData)

  // Flush queue every 5 events or 2 seconds
  if (eventQueue.length >= 5) {
    flushEventQueue()
  }
}

function flushEventQueue() {
  eventQueue.forEach(data => {
    window.dataLayer.push(data)
  })
  eventQueue.length = 0
}

// Auto-flush every 2 seconds
setInterval(flushEventQueue, 2000)

Environment Management

Multiple Containers

// Load different containers per environment
const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID || (
  process.env.NODE_ENV === 'production'
    ? 'GTM-PROD123'
    : 'GTM-DEV456'
)

export default function App({ Component, pageProps }) {
  return (
    <>
      <Script id="gtm-script">
        {`/* GTM with ${GTM_ID} */`}
      </Script>
      <Component {...pageProps} />
    </>
  )
}

Preview Mode Exclusion

// Next.js - don't load GTM in preview mode
import { draftMode } from 'next/headers'

export default async function RootLayout({ children }) {
  const { isEnabled } = draftMode()

  return (
    <html>
      <body>
        {!isEnabled && <GTMScript />}
        {children}
      </body>
    </html>
  )
}

Testing and Debugging

GTM Preview Mode

  1. Open GTM container
  2. Click Preview
  3. Enter your Sanity-powered site URL
  4. Debug tag firing, variables, and data layer

Console Debugging

// Log all data layer pushes
const originalPush = window.dataLayer.push
window.dataLayer.push = function() {
  console.log('Data Layer Push:', arguments[0])
  return originalPush.apply(window.dataLayer, arguments)
}

// View entire data layer
console.table(window.dataLayer)

Common Issues

See Troubleshooting Events Not Firing for detailed debugging steps.

Privacy and Compliance

// Initialize with consent defaults
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
  event: 'consent_default',
  analytics_storage: 'denied',
  ad_storage: 'denied'
})

// Update after user consent
function updateConsent(granted) {
  window.dataLayer.push({
    event: 'consent_update',
    analytics_storage: granted ? 'granted' : 'denied',
    ad_storage: granted ? 'granted' : 'denied'
  })
}

Exclude Internal Traffic

// Don't load GTM for internal IPs or roles
const isInternal =
  window.location.hostname === 'localhost' ||
  window.location.search.includes('internal=true')

if (!isInternal) {
  loadGTM()
}

Additional Resources

// SYS.FOOTER