Data Layer Structure for Sanity + GTM | Blue Frog Docs

Data Layer Structure for Sanity + GTM

Complete reference for implementing a custom data layer for Sanity content with Google Tag Manager.

Data Layer Structure for Sanity + GTM

Unlike platforms with native data layers (like Shopify), Sanity requires you to build a custom data layer in your frontend application. This guide covers data layer architecture and implementation for Sanity-powered sites.

Data Layer Overview

The data layer is a JavaScript object that stores information about your Sanity content, user interactions, and page state. GTM reads this data to populate variables and trigger tags.

How It Works

  1. Frontend fetches Sanity content via GROQ/GraphQL
  2. Application pushes content data to window.dataLayer
  3. GTM reads data layer and populates variables
  4. Tags fire with Sanity-specific data

Base Data Layer Structure

Page Load Data Layer

Push on every page load:

window.dataLayer = window.dataLayer || []

window.dataLayer.push({
  event: 'page_load',
  page: {
    type: 'sanity_page',
    path: window.location.pathname,
    title: document.title,
    locale: 'en-US',
    environment: process.env.NODE_ENV,
  },
  user: {
    id: null,        // Hashed user ID if logged in
    type: 'guest',   // 'guest', 'member', 'subscriber'
    loginState: 'logged_out',
  },
  sanity: {
    projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
    dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
  },
})

Content-Specific Data Layer

Push when Sanity content is viewed:

window.dataLayer.push({
  event: 'content_view',
  content: {
    type: document._type,                    // 'post', 'product', etc.
    id: document._id,                        // Sanity document ID
    title: document.title,
    slug: document.slug?.current,
    category: document.category?.title || 'uncategorized',
    tags: document.tags?.map(t => t.title) || [],
    author: {
      name: document.author?.name || 'unknown',
      id: document.author?._id || null,
    },
    publishDate: document.publishedAt || document._createdAt,
    updateDate: document._updatedAt,
    locale: document.language,
    revision: document._rev,
  },
})

Framework-Specific Implementations

Next.js (App Router)

Create Data Layer Utility:

// utils/dataLayer.ts
export function pushToDataLayer(data: Record<string, any>) {
  if (typeof window !== 'undefined') {
    window.dataLayer = window.dataLayer || []
    window.dataLayer.push(data)
  }
}

export function createContentDataLayer(document: any) {
  return {
    event: 'content_view',
    content: {
      type: document._type,
      id: document._id,
      title: document.title,
      category: document.category?.title,
      tags: document.tags?.map(t => t.title).join(',') || '',
      author: document.author?.name || '',
      publishDate: document.publishedAt || document._createdAt,
      locale: document.language,
    },
  }
}

Use in Component:

// app/blog/[slug]/page.tsx
'use client'

import { useEffect } from 'react'
import { pushToDataLayer, createContentDataLayer } from '@/utils/dataLayer'

export default function BlogPost({ post }) {
  useEffect(() => {
    pushToDataLayer(createContentDataLayer(post))
  }, [post])

  return <article>{/* Content */}</article>
}

Track Page Views:

// app/components/PageViewTracker.tsx
'use client'

import { usePathname } from 'next/navigation'
import { useEffect } from 'react'

export function PageViewTracker() {
  const pathname = usePathname()

  useEffect(() => {
    window.dataLayer?.push({
      event: 'pageview',
      page: {
        path: pathname,
        title: document.title,
      },
    })
  }, [pathname])

  return null
}

Next.js (Pages Router)

// pages/_app.tsx
import { useEffect } from 'react'
import { useRouter } from 'next/router'

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

  useEffect(() => {
    const handleRouteChange = (url: string) => {
      window.dataLayer?.push({
        event: 'pageview',
        page: {
          path: url,
          title: document.title,
        },
      })
    }

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

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

// pages/blog/[slug].tsx
import { useEffect } from 'react'

export default function BlogPost({ post }) {
  useEffect(() => {
    if (typeof window !== 'undefined' && post) {
      window.dataLayer?.push({
        event: 'content_view',
        content: {
          type: post._type,
          id: post._id,
          title: post.title,
        },
      })
    }
  }, [post])

  return <article>{/* Content */}</article>
}

Gatsby

Create Data Layer Plugin:

// gatsby-browser.js
export const onRouteUpdate = ({ location, prevLocation }) => {
  if (typeof window !== 'undefined' && window.dataLayer) {
    window.dataLayer.push({
      event: 'pageview',
      page: {
        path: location.pathname,
        title: document.title,
      },
    })
  }
}

// Template: src/templates/blog-post.js
import React, { useEffect } from 'react'

const BlogPostTemplate = ({ data }) => {
  const post = data.sanityPost

  useEffect(() => {
    if (typeof window !== 'undefined' && window.dataLayer) {
      window.dataLayer.push({
        event: 'content_view',
        content: {
          type: post._type,
          id: post._id,
          title: post.title,
          category: post.category?.title || 'uncategorized',
          tags: post.tags?.map(t => t.title).join(',') || '',
          author: post.author?.name || '',
          publishDate: post.publishedAt || post._createdAt,
        },
      })
    }
  }, [post])

  return <article>{/* Content */}</article>
}

export default BlogPostTemplate

React SPA

// App.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'

function App() {
  const location = useLocation()

  useEffect(() => {
    window.dataLayer?.push({
      event: 'pageview',
      page: {
        path: location.pathname,
        title: document.title,
      },
    })
  }, [location])

  return <Router>{/* Routes */}</Router>
}

// ContentPage.tsx
export function ContentPage({ sanityDocument }) {
  useEffect(() => {
    window.dataLayer?.push({
      event: 'content_view',
      content: {
        type: sanityDocument._type,
        id: sanityDocument._id,
        title: sanityDocument.title,
      },
    })
  }, [sanityDocument])

  return <div>{/* Content */}</div>
}

Data Layer Events

Standard Events

page_load - Initial page load:

{
  event: 'page_load',
  page: {
    type: 'sanity_page',
    path: '/blog/example-post',
    title: 'Example Post',
    locale: 'en-US',
  },
  sanity: {
    projectId: 'abc123',
    dataset: 'production',
  }
}

pageview - SPA route changes:

{
  event: 'pageview',
  page: {
    path: '/new-page',
    title: 'New Page Title',
    referrer: document.referrer,
  }
}

content_view - Sanity content viewed:

{
  event: 'content_view',
  content: {
    type: 'post',
    id: 'doc-id-123',
    title: 'Blog Post Title',
    category: 'Technology',
    tags: ['javascript', 'sanity'],
    author: {
      name: 'John Doe',
      id: 'author-id-456',
    },
    publishDate: '2024-01-15',
    updateDate: '2024-01-20',
  }
}

Custom Events

search - Content search:

{
  event: 'search',
  search: {
    term: 'query text',
    results_count: 15,
    content_types: ['post', 'page'],
  }
}

content_engagement - User engagement:

{
  event: 'content_engagement',
  engagement: {
    content_id: 'doc-id-123',
    content_type: 'post',
    engagement_type: 'scroll_75',
    time_on_page: 120, // seconds
  }
}

form_submit - Form submissions:

{
  event: 'form_submit',
  form: {
    name: 'contact_form',
    type: 'contact',
    success: true,
    content_id: 'doc-id-123', // Related Sanity doc
  }
}

file_download - Sanity asset downloads:

{
  event: 'file_download',
  file: {
    name: 'whitepaper.pdf',
    extension: 'pdf',
    size: 1024000, // bytes
    asset_id: 'image-abc123',
    content_id: 'doc-id-123',
  }
}

E-commerce Data Layer

If using Sanity for product content:

view_item - Product viewed:

{
  event: 'view_item',
  ecommerce: {
    currency: 'USD',
    value: 29.99,
    items: [{
      item_id: product._id,
      item_name: product.title,
      item_brand: product.brand,
      item_category: product.category?.title,
      price: product.price,
    }]
  }
}

add_to_cart - Item added to cart:

{
  event: 'add_to_cart',
  ecommerce: {
    currency: 'USD',
    value: 29.99,
    items: [{
      item_id: product._id,
      item_name: product.title,
      quantity: 1,
      price: product.price,
    }]
  }
}

purchase - Order completed:

{
  event: 'purchase',
  ecommerce: {
    transaction_id: 'order-123',
    value: 99.99,
    currency: 'USD',
    tax: 8.50,
    shipping: 5.00,
    items: [
      // Array of product items
    ]
  }
}

GTM Variable Configuration

Create Data Layer Variables

In GTM, create variables to read from data layer:

Content Type Variable:

  1. VariablesNew
  2. Variable Type: Data Layer Variable
  3. Data Layer Variable Name: content.type
  4. Name: DLV - Content Type
  5. Save

Content ID Variable:

  1. Variable Type: Data Layer Variable
  2. Data Layer Variable Name: content.id
  3. Name: DLV - Content ID
  4. Save

Content Title Variable:

  1. Variable Type: Data Layer Variable
  2. Data Layer Variable Name: content.title
  3. Name: DLV - Content Title
  4. Save

Additional Variables:

  • DLV - Content Category: content.category
  • DLV - Content Tags: content.tags
  • DLV - Content Author: content.author.name
  • DLV - Publish Date: content.publishDate
  • DLV - Sanity Project ID: sanity.projectId
  • DLV - Sanity Dataset: sanity.dataset

Create Custom Triggers

Content View Trigger:

  1. TriggersNew
  2. Trigger Type: Custom Event
  3. Event name: content_view
  4. Name: Custom Event - Content View
  5. Save

Search Trigger:

  1. Trigger Type: Custom Event
  2. Event name: search
  3. Name: Custom Event - Search
  4. Save

Advanced Data Layer Patterns

Portable Text Content Analysis

Extract data from Portable Text:

import { toPlainText } from '@portabletext/react'

export function analyzePortableText(portableText: any) {
  const plainText = toPlainText(portableText)

  return {
    word_count: plainText.split(/\s+/).length,
    char_count: plainText.length,
    has_images: portableText.some((block: any) => block._type === 'image'),
    has_code: portableText.some((block: any) => block._type === 'code'),
  }
}

// Use in data layer
window.dataLayer.push({
  event: 'content_view',
  content: {
    ...contentData,
    ...analyzePortableText(document.body),
  },
})

Reference Tracking

Track referenced Sanity documents:

export function extractReferences(document: any) {
  const references = []

  // Extract author reference
  if (document.author?._ref) {
    references.push({
      type: 'author',
      id: document.author._ref,
    })
  }

  // Extract category reference
  if (document.category?._ref) {
    references.push({
      type: 'category',
      id: document.category._ref,
    })
  }

  return references
}

window.dataLayer.push({
  event: 'content_view',
  content: {
    ...contentData,
    references: extractReferences(document),
  },
})

Asset Metadata Tracking

Track Sanity image/file metadata:

import { getImageDimensions } from '@sanity/asset-utils'

export function trackImageMetadata(imageAsset: any) {
  const { width, height, aspectRatio } = getImageDimensions(imageAsset)

  return {
    asset_id: imageAsset._id,
    width: width,
    height: height,
    aspect_ratio: aspectRatio,
    format: imageAsset.extension,
    size: imageAsset.size,
  }
}

window.dataLayer.push({
  event: 'image_view',
  image: trackImageMetadata(document.mainImage.asset),
})

Performance Optimization

Lazy Load Data Layer

Only push data when needed:

let contentDataPushed = false

export function pushContentData(document: any) {
  if (contentDataPushed) return

  window.dataLayer?.push({
    event: 'content_view',
    content: createContentDataLayer(document),
  })

  contentDataPushed = true
}

Debounce Data Layer Pushes

Prevent excessive pushes:

import { debounce } from 'lodash'

const pushToDataLayerDebounced = debounce((data: any) => {
  window.dataLayer?.push(data)
}, 300)

// Usage
pushToDataLayerDebounced({
  event: 'scroll',
  scroll_depth: 50,
})

Minimize Data Size

Only push necessary data:

// Bad - too much data
dataLayer.push({
  event: 'content_view',
  entire_sanity_document: sanityDocument, // Contains everything
})

// Good - only needed fields
dataLayer.push({
  event: 'content_view',
  content: {
    id: sanityDocument._id,
    type: sanityDocument._type,
    title: sanityDocument.title,
  }
})

Testing Data Layer

Browser Console

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

// Monitor new pushes
const originalPush = window.dataLayer.push
window.dataLayer.push = function() {
  console.log('📊 DataLayer Push:', arguments[0])
  return originalPush.apply(window.dataLayer, arguments)
}

// Find specific events
window.dataLayer.filter(item => item.event === 'content_view')

GTM Preview Mode

  1. Open GTM → Preview
  2. Enter site URL
  3. Navigate site
  4. Check Data Layer tab
  5. Verify:
    • Events fire correctly
    • Variables populate
    • Data structure correct

dataLayer Inspector Extension

Install dataLayer Inspector Chrome extension:

  • Real-time data layer view
  • Shows all pushes
  • Validates structure

Common Issues

Data Layer Not Initializing

Problem: window.dataLayer is undefined

Fix: Initialize before GTM loads:

// In layout/document, before GTM script
<script
  dangerouslySetInnerHTML={{
    __html: `window.dataLayer = window.dataLayer || [];`,
  }}
/>

Race Conditions

Problem: Data pushed before GTM reads it.

Fix: Use GTM's built-in queue:

// This queues correctly
window.dataLayer = window.dataLayer || []
window.dataLayer.push({ event: 'content_view' })

Duplicate Events

Problem: Same event firing multiple times.

Fix: Use ref to prevent duplicates:

const pushed = useRef(false)

useEffect(() => {
  if (pushed.current) return
  pushed.current = true

  window.dataLayer?.push({ event: 'content_view' })
}, [])

Best Practices

  1. Initialize Early: Set up data layer before GTM loads
  2. Consistent Naming: Use standardized event and variable names
  3. Validate Data: Ensure data is present before pushing
  4. Document Structure: Maintain documentation of data layer schema
  5. Version Events: Include version numbers for schema changes
  6. Test Thoroughly: Use Preview mode before deploying
  7. Monitor Performance: Watch for excessive pushes

Next Steps

For general data layer concepts, see Data Layer Guide.

// SYS.FOOTER