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:
- Google Tag Manager account created
- GTM container ID (format: GTM-XXXXXXX)
- Sanity project with GROQ API access
- Frontend framework deployed
- 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
- Open GTM container
- Click Preview
- Enter your Sanity-powered site URL
- 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
Consent Mode
// 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()
}