Sanity Google Analytics Integration
Complete guide to setting up Google Analytics 4 (GA4) on your Sanity-powered website for comprehensive content tracking, user behavior analysis, and performance monitoring.
Getting Started
Choose the implementation approach that best fits your frontend framework:
GA4 Setup Guide
Step-by-step instructions for installing GA4 on Sanity-powered sites using Next.js, Gatsby, Remix, and other frameworks. Includes GROQ query integration and content tracking setup.
Custom Event Tracking
Track custom interactions with Sanity content including article reads, content downloads, Portable Text engagement, and custom content type interactions.
Why GA4 for Sanity?
GA4 provides powerful analytics capabilities for headless CMS implementations:
- Content Performance Insights: Track which Sanity content types and documents drive engagement
- User Journey Mapping: Understand how users navigate through your content
- GROQ-Enhanced Tracking: Enrich events with content metadata from Sanity
- Framework Compatibility: Works with Next.js, Gatsby, Remix, SvelteKit, and more
- Real-time Content Analytics: Monitor content updates and their impact
- Custom Dimensions: Track Sanity-specific metadata (document types, authors, categories)
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 |
| GTM Implementation | Complex tracking, multiple tools | Moderate | All frameworks |
| Custom Client Library | Full control, custom requirements | Advanced | All frameworks |
| Server-Side Events API | Privacy-focused, ad-blocker resistant | Advanced | All frameworks |
Prerequisites
Before starting:
- Google Analytics 4 property created
- Sanity project with GROQ API access
- Frontend framework (Next.js, Gatsby, etc.) deployed
- Basic understanding of your content schema
Sanity-Specific Tracking Features
Content Metadata Tracking
Leverage Sanity's content structure for rich analytics:
// Fetch content with GROQ for tracking
const contentData = await client.fetch(`
*[_type == "post" && slug.current == $slug][0]{
_id,
_type,
_rev,
title,
"slug": slug.current,
publishedAt,
"author": author->name,
"categories": categories[]->title,
"readingTime": round(length(pt::text(body)) / 5 / 180)
}
`, { slug })
// Track with Sanity metadata
gtag('event', 'view_item', {
item_id: contentData._id,
item_name: contentData.title,
item_category: contentData.categories?.join(','),
content_type: contentData._type,
author: contentData.author,
reading_time_minutes: contentData.readingTime
})
Portable Text Engagement
Track user interaction with rich content blocks:
// Track scroll depth through Portable Text blocks
function trackPortableTextEngagement(blockIndex, blockType) {
gtag('event', 'content_block_view', {
block_index: blockIndex,
block_type: blockType,
content_id: contentData._id
})
}
Document Revision Tracking
Monitor content updates and their impact:
gtag('event', 'content_version_view', {
document_id: contentData._id,
document_revision: contentData._rev,
published_date: contentData.publishedAt
})
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
}
`)
gtag('config', 'G-XXXXXXXXXX', {
content_group: localizedContent.__i18n_lang
})
Framework-Specific Examples
Next.js App Router
// app/layout.tsx
import { GoogleAnalytics } from '@next/third-parties/google'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<GoogleAnalytics gaId="G-XXXXXXXXXX" />
</body>
</html>
)
}
Next.js Pages Router
// pages/_app.js
import Script from 'next/script'
export default function App({ Component, pageProps }) {
return (
<>
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
`}
</Script>
<Component {...pageProps} />
</>
)
}
Gatsby
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: `gatsby-plugin-google-gtag`,
options: {
trackingIds: ['G-XXXXXXXXXX'],
pluginConfig: {
head: true,
respectDNT: true,
exclude: ['/preview/**'],
},
},
},
],
}
Remix
// app/root.tsx
import { useLocation } from '@remix-run/react'
import { useEffect } from 'react'
export default function Root() {
const location = useLocation()
useEffect(() => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('config', 'G-XXXXXXXXXX', {
page_path: location.pathname
})
}
}, [location])
return (
<html>
<head>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
`
}}
/>
</head>
<body>
<Outlet />
</body>
</html>
)
}
GROQ Query Best Practices for Analytics
Optimize Query Performance
// Fetch only needed fields for tracking
const query = `*[_type == "post" && slug.current == $slug][0]{
_id,
_type,
title,
"categoryName": category->title
}`
// Instead of fetching entire document
const badQuery = `*[_type == "post" && slug.current == $slug][0]`
Use Projections
// Efficient content metadata query
const analyticsQuery = groq`{
"id": _id,
"type": _type,
"title": title,
"author": author->name,
"tags": tags[]->name,
"publishDate": publishedAt,
"wordCount": length(pt::text(body))
}`
Handle Missing References
// Graceful fallback for missing refs
const safeQuery = groq`{
_id,
title,
"author": coalesce(author->name, "Unknown"),
"category": coalesce(category->title, "Uncategorized")
}`
Performance Considerations
Avoid Blocking Renders
Use framework-specific async loading:
// Next.js - defer analytics
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
strategy="afterInteractive" // Loads after page is interactive
/>
Cache GROQ Queries
// Use SWR or React Query for caching
import useSWR from 'swr'
function useContentAnalytics(slug) {
const { data } = useSWR(
['analytics', slug],
() => client.fetch(analyticsQuery, { slug }),
{ revalidateOnFocus: false }
)
return data
}
Batch Analytics Events
// Collect events, send in batches
const eventQueue = []
function queueEvent(eventName, params) {
eventQueue.push({ eventName, params })
if (eventQueue.length >= 10) {
flushEvents()
}
}
function flushEvents() {
eventQueue.forEach(({ eventName, params }) => {
gtag('event', eventName, params)
})
eventQueue.length = 0
}
Privacy and Compliance
Exclude Preview Mode
// Next.js - don't track preview sessions
import { draftMode } from 'next/headers'
export default async function Page() {
const { isEnabled } = draftMode()
return (
<div>
{!isEnabled && <Analytics />}
<Content />
</div>
)
}
Consent Mode Implementation
// Initialize with consent defaults
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied'
})
// Update after user consent
function handleConsent(granted) {
gtag('consent', 'update', {
'analytics_storage': granted ? 'granted' : 'denied'
})
}
PII Protection
// Sanitize content before tracking
function sanitizeForTracking(content) {
return {
id: content._id,
type: content._type,
// Never include user emails, names from comments, etc.
title: content.title,
category: content.category?.title
}
}
Common Issues and Solutions
Issue: Events Not Firing on Client-Side Navigation
Cause: SPA frameworks don't reload page on navigation
Solution: Track route changes manually
// Next.js example
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export default function Analytics() {
const router = useRouter()
useEffect(() => {
const handleRouteChange = (url) => {
gtag('config', 'G-XXXXXXXXXX', {
page_path: url
})
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [router.events])
return null
}
Issue: Content Metadata Not Available
Cause: GROQ query executes before content is fetched
Solution: Use loading states or SSR
// Fetch content server-side
export async function getStaticProps({ params }) {
const content = await client.fetch(query, { slug: params.slug })
return {
props: { content }
}
}
// Track after content is available
export default function Page({ content }) {
useEffect(() => {
if (content) {
trackContentView(content)
}
}, [content])
return <Content data={content} />
}