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
- Frontend fetches Sanity content via GROQ/GraphQL
- Application pushes content data to
window.dataLayer - GTM reads data layer and populates variables
- 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:
- Variables → New
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
content.type - Name:
DLV - Content Type - Save
Content ID Variable:
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
content.id - Name:
DLV - Content ID - Save
Content Title Variable:
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
content.title - Name:
DLV - Content Title - Save
Additional Variables:
DLV - Content Category:content.categoryDLV - Content Tags:content.tagsDLV - Content Author:content.author.nameDLV - Publish Date:content.publishDateDLV - Sanity Project ID:sanity.projectIdDLV - Sanity Dataset:sanity.dataset
Create Custom Triggers
Content View Trigger:
- Triggers → New
- Trigger Type: Custom Event
- Event name:
content_view - Name:
Custom Event - Content View - Save
Search Trigger:
- Trigger Type: Custom Event
- Event name:
search - Name:
Custom Event - Search - 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
- Open GTM → Preview
- Enter site URL
- Navigate site
- Check Data Layer tab
- 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
- Initialize Early: Set up data layer before GTM loads
- Consistent Naming: Use standardized event and variable names
- Validate Data: Ensure data is present before pushing
- Document Structure: Maintain documentation of data layer schema
- Version Events: Include version numbers for schema changes
- Test Thoroughly: Use Preview mode before deploying
- Monitor Performance: Watch for excessive pushes
Next Steps
- GTM Setup - Install GTM on Sanity sites
- GA4 Events - Configure event tracking
- Troubleshoot Tracking - Debug data layer issues
For general data layer concepts, see Data Layer Guide.