Install Google Analytics 4 with Sanity
Since Sanity is a headless CMS, GA4 is installed in your frontend application, not in Sanity Studio itself. The implementation method depends on your framework (Next.js, Gatsby, Nuxt, React, etc.).
Before You Begin
Prerequisites:
- Active GA4 property with Measurement ID (format:
G-XXXXXXXXXX) - Sanity content integrated into your frontend application
- Developer access to your frontend codebase
- Understanding of your framework's structure
Important: Sanity only stores and delivers content via GROQ/GraphQL APIs. All analytics code lives in your frontend framework.
Implementation by Framework
Method 1: Next.js (App Router) - Recommended for New Projects
Next.js 13+ with App Router is the recommended approach for modern Sanity sites.
Setup Steps
1. Create Analytics Component
Create app/components/Analytics.tsx:
'use client'
import Script from 'next/script'
export function Analytics() {
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID
if (!GA_MEASUREMENT_ID) {
console.warn('GA4 Measurement ID not found')
return null
}
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
page_path: window.location.pathname,
});
`}
</Script>
</>
)
}
2. Add to Root Layout
Update app/layout.tsx:
import { Analytics } from './components/Analytics'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
</body>
</html>
)
}
3. Track Route Changes
Create app/components/PageViewTracker.tsx:
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
export function PageViewTracker() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (typeof window !== 'undefined' && window.gtag) {
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : '')
window.gtag('config', process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID!, {
page_path: url,
})
}
}, [pathname, searchParams])
return null
}
Add to root layout:
import { PageViewTracker } from './components/PageViewTracker'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<PageViewTracker />
</body>
</html>
)
}
4. Set Environment Variables
Create .env.local:
NEXT_PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
5. Track Sanity Content Views
In your Sanity content pages:
// app/blog/[slug]/page.tsx
import { useEffect } from 'react'
import { client } from '@/lib/sanity.client'
export default async function BlogPost({ params }) {
const post = await client.fetch(`*[_type == "post" && slug.current == $slug][0]`, {
slug: params.slug
})
return (
<div>
<ContentTracker
contentType="blog_post"
contentId={post._id}
title={post.title}
category={post.category}
/>
<article>{/* Content */}</article>
</div>
)
}
// components/ContentTracker.tsx
'use client'
export function ContentTracker({ contentType, contentId, title, category }) {
useEffect(() => {
if (window.gtag) {
window.gtag('event', 'content_view', {
content_type: contentType,
content_id: contentId,
title: title,
category: category,
})
}
}, [contentType, contentId, title, category])
return null
}
Method 2: Next.js (Pages Router)
For Next.js projects using the Pages Router.
1. Create Custom Document
Create or update pages/_document.tsx:
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID
return (
<Html>
<Head>
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
page_path: window.location.pathname,
});
`,
}}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
2. Track Route Changes
Update pages/_app.tsx:
import { useEffect } from 'react'
import { useRouter } from 'next/router'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter()
useEffect(() => {
const handleRouteChange = (url: string) => {
if (window.gtag) {
window.gtag('config', process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID!, {
page_path: url,
})
}
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => {
router.events.off('routeChangeComplete', handleRouteChange)
}
}, [router.events])
return <Component {...pageProps} />
}
Method 3: Gatsby
Perfect for static Sanity sites with excellent build-time optimization.
1. Install Plugin
npm install gatsby-plugin-google-gtag
2. Configure Plugin
Update gatsby-config.js:
module.exports = {
plugins: [
{
resolve: `gatsby-source-sanity`,
options: {
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
},
},
{
resolve: `gatsby-plugin-google-gtag`,
options: {
trackingIds: [
process.env.GA_MEASUREMENT_ID, // Google Analytics
],
gtagConfig: {
anonymize_ip: true,
cookie_expires: 0,
},
pluginConfig: {
head: false,
respectDNT: true,
},
},
},
],
}
3. Set Environment Variables
Create .env.production:
GA_MEASUREMENT_ID=G-XXXXXXXXXX
SANITY_PROJECT_ID=your-project-id
SANITY_DATASET=production
4. Track Sanity Content
// src/templates/blog-post.js
import React, { useEffect } from 'react'
const BlogPostTemplate = ({ data }) => {
const post = data.sanityPost
useEffect(() => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'content_view', {
content_type: 'blog_post',
content_id: post._id,
title: post.title,
category: post.category,
author: post.author?.name,
})
}
}, [post])
return (
<article>
<h1>{post.title}</h1>
{/* Content */}
</article>
)
}
export default BlogPostTemplate
5. Manual Implementation (Alternative)
If not using the plugin, add to gatsby-ssr.js:
export const onRenderBody = ({ setHeadComponents }) => {
const GA_ID = process.env.GA_MEASUREMENT_ID
if (!GA_ID) return
setHeadComponents([
<script
key="gtag-js"
async
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
/>,
<script
key="gtag-config"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`,
}}
/>,
])
}
Method 4: Nuxt.js
Ideal for Vue developers building Sanity sites.
1. Install Module
npm install @nuxtjs/google-analytics
2. Configure in nuxt.config.js
export default {
modules: [
[
'@nuxtjs/google-analytics',
{
id: process.env.GA_MEASUREMENT_ID,
},
],
],
// Or for Nuxt 3
buildModules: [
['@nuxtjs/google-analytics', {
id: process.env.GA_MEASUREMENT_ID,
debug: {
enabled: process.env.NODE_ENV !== 'production',
sendHitTask: process.env.NODE_ENV === 'production',
},
}],
],
publicRuntimeConfig: {
gaMeasurementId: process.env.GA_MEASUREMENT_ID,
},
}
3. Track Sanity Content
<template>
<article>
<h1>{{ post.title }}</h1>
<!-- Content -->
</article>
</template>
<script>
export default {
async asyncData({ $sanity, params }) {
const query = `*[_type == "post" && slug.current == $slug][0]`
const post = await $sanity.fetch(query, { slug: params.slug })
return { post }
},
mounted() {
if (this.$ga) {
this.$ga.event('content_view', {
content_type: this.post._type,
content_id: this.post._id,
title: this.post.title,
})
}
},
}
</script>
Method 5: React SPA (Vite, Create React App)
For single-page applications consuming Sanity.
1. Add to index.html
Update public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
2. Track Route Changes with React Router
// App.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
function App() {
const location = useLocation()
useEffect(() => {
if (window.gtag) {
window.gtag('config', 'G-XXXXXXXXXX', {
page_path: location.pathname + location.search,
})
}
}, [location])
return <Router>{/* Routes */}</Router>
}
3. Use Environment Variables
Create .env:
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
# or for CRA
REACT_APP_GA_MEASUREMENT_ID=G-XXXXXXXXXX
Update implementation:
const GA_ID = import.meta.env.VITE_GA_MEASUREMENT_ID // Vite
// or
const GA_ID = process.env.REACT_APP_GA_MEASUREMENT_ID // CRA
Advanced Configuration
Content-Specific Tracking
Track which Sanity content types perform best:
export function trackSanityContent(document: any) {
if (!window.gtag) return
window.gtag('event', 'content_view', {
content_type: document._type,
content_id: document._id,
title: document.title,
category: document.category,
tags: document.tags?.map(t => t.title).join(','),
author: document.author?.name,
publish_date: document.publishedAt || document._createdAt,
locale: document.language || 'en',
revision: document._rev,
})
}
GROQ Queries for Analytics Integration
Use GROQ to query Sanity content alongside analytics needs:
Fetch Content with Analytics Metadata:
*[_type == "post" && publishedAt < now()] | order(publishedAt desc) {
_id,
_type,
title,
slug,
publishedAt,
_createdAt,
_updatedAt,
_rev,
"author": author->name,
"authorId": author->_id,
"categories": categories[]->title,
"categoryIds": categories[]->_id,
"tags": tags[]->title,
"estimatedReadingTime": round(length(pt::text(body)) / 200),
"wordCount": length(pt::text(body)),
"imageCount": length(body[_type == "image"]),
mainImage {
asset-> {
url,
metadata {
dimensions,
lqip
}
}
}
}
Query Content Performance Data:
// Fetch posts with custom analytics fields
*[_type == "post"] {
_id,
title,
slug,
publishedAt,
// Calculate content age
"daysPublished": round((now() - publishedAt) / 86400),
// Embed analytics data (if stored in Sanity)
"analytics": *[_type == "analytics" && postId == ^._id][0] {
pageViews,
uniqueVisitors,
averageTimeOnPage,
bounceRate
},
// Content metadata useful for analytics
"metadata": {
"wordCount": length(pt::text(body)),
"hasVideo": defined(body[_type == "videoEmbed"][0]),
"hasCTA": defined(body[_type == "ctaBlock"][0]),
"internalLinks": count(body[].markDefs[_type == "internalLink"]),
"externalLinks": count(body[].markDefs[_type == "link"])
}
}
Top Performing Content Query:
*[_type == "post" && defined(analytics.pageViews)]
| order(analytics.pageViews desc)
[0...10] {
title,
slug,
"views": analytics.pageViews,
"engagement": analytics.averageTimeOnPage,
publishedAt,
author->name
}
Webhook Integration for Server-Side Events
Send server-side GA4 events when Sanity content changes using webhooks.
1. Create Webhook Handler:
// pages/api/sanity-webhook.ts (Next.js)
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Verify webhook signature (recommended)
const signature = req.headers['sanity-webhook-signature']
// Implement signature verification...
const { _type, _id, _rev, slug, title } = req.body
// Send event to GA4 Measurement Protocol
const GA4_MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID
const GA4_API_SECRET = process.env.GA4_API_SECRET
await fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${GA4_MEASUREMENT_ID}&api_secret=${GA4_API_SECRET}`, {
method: 'POST',
body: JSON.stringify({
client_id: 'sanity-webhook',
events: [{
name: 'content_published',
params: {
content_type: _type,
content_id: _id,
content_title: title,
content_slug: slug?.current,
revision: _rev,
}
}]
})
})
res.status(200).json({ success: true })
}
2. Configure Webhook in Sanity:
In Sanity Studio, go to Manage → API → Webhooks, and create a webhook:
- Name: GA4 Content Events
- URL:
https://yoursite.com/api/sanity-webhook - Trigger on: Create, Update, Delete
- Dataset: production
- Filter:
_type == "post"(or other content types) - Projection:
{ _type, _id, _rev, slug, title } - HTTP method: POST
- Secret: (Add a secret for signature verification)
3. Webhook Event Types:
Track different content lifecycle events:
async function sendGA4Event(eventName: string, params: any) {
const GA4_MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID
const GA4_API_SECRET = process.env.GA4_API_SECRET
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${GA4_MEASUREMENT_ID}&api_secret=${GA4_API_SECRET}`,
{
method: 'POST',
body: JSON.stringify({
client_id: 'sanity-cms',
events: [{ name: eventName, params }]
})
}
)
}
// Handle different webhook events
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { _type, _id, title, publishedAt } = req.body
const action = req.headers['sanity-webhook-event'] // create, update, delete
const params = {
content_type: _type,
content_id: _id,
content_title: title,
}
switch (action) {
case 'create':
await sendGA4Event('content_created', params)
break
case 'update':
await sendGA4Event('content_updated', params)
break
case 'delete':
await sendGA4Event('content_deleted', params)
break
}
res.status(200).json({ success: true })
}
Real-Time Content Preview Tracking
Track preview sessions separately from published content:
'use client'
import { useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
export function PreviewTracker({ document }: { document: any }) {
const searchParams = useSearchParams()
const isPreview = searchParams.get('preview') === 'true'
useEffect(() => {
if (!window.gtag) return
if (isPreview) {
// Track preview session
window.gtag('event', 'content_preview', {
content_type: document._type,
content_id: document._id,
content_title: document.title,
is_draft: document._id.startsWith('drafts.'),
preview_mode: true,
non_interaction: true, // Don't affect bounce rate
})
}
}, [document, isPreview])
return null
}
Track Content Engagement Depth
Monitor how deeply users engage with Sanity content:
'use client'
import { useEffect } from 'react'
export function ContentEngagementTracker({
contentId,
contentType,
wordCount
}: {
contentId: string
contentType: string
wordCount: number
}) {
useEffect(() => {
let scrollDepth = 0
let timeOnPage = 0
const startTime = Date.now()
// Track scroll depth
const handleScroll = () => {
const winScroll = window.scrollY
const height = document.documentElement.scrollHeight - window.innerHeight
const scrolled = Math.round((winScroll / height) * 100)
// Fire events at 25%, 50%, 75%, 100%
const milestones = [25, 50, 75, 100]
const currentMilestone = milestones.find(m => scrolled >= m && scrollDepth < m)
if (currentMilestone && window.gtag) {
scrollDepth = currentMilestone
window.gtag('event', 'content_scroll_depth', {
content_id: contentId,
content_type: contentType,
scroll_depth: currentMilestone,
time_on_page: Math.round((Date.now() - startTime) / 1000),
})
}
}
// Track time on page
const trackTimeOnPage = () => {
const seconds = Math.round((Date.now() - startTime) / 1000)
if (window.gtag && seconds > 0 && seconds % 30 === 0) {
window.gtag('event', 'content_engagement', {
content_id: contentId,
content_type: contentType,
time_on_page: seconds,
estimated_read_time: Math.round(wordCount / 200), // 200 wpm
completion_rate: Math.round((seconds / (wordCount / 200 * 60)) * 100),
})
}
}
const scrollInterval = setInterval(handleScroll, 1000)
const timeInterval = setInterval(trackTimeOnPage, 1000)
window.addEventListener('scroll', handleScroll)
return () => {
clearInterval(scrollInterval)
clearInterval(timeInterval)
window.removeEventListener('scroll', handleScroll)
}
}, [contentId, contentType, wordCount])
return null
}
Multi-Dataset Analytics
Track events differently based on Sanity dataset:
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET
// Use different GA4 properties for different datasets
const GA_MEASUREMENT_ID =
dataset === 'production'
? process.env.NEXT_PUBLIC_GA_PRODUCTION_ID
: dataset === 'staging'
? process.env.NEXT_PUBLIC_GA_STAGING_ID
: process.env.NEXT_PUBLIC_GA_DEV_ID
// Tag all events with dataset
if (window.gtag) {
window.gtag('set', {
sanity_dataset: dataset,
environment: process.env.VERCEL_ENV || 'development'
})
}
Sanity Studio Draft Exclusion
Don't track Sanity Studio preview sessions:
const isDraft = document._id.startsWith('drafts.')
const isPreview = searchParams.get('preview') === 'true'
if (!isDraft && !isPreview && GA_MEASUREMENT_ID) {
// Initialize GA4
}
Multi-Environment Setup
Different analytics properties for each environment:
const GA_MEASUREMENT_ID =
process.env.VERCEL_ENV === 'production'
? process.env.NEXT_PUBLIC_GA_PRODUCTION_ID
: process.env.VERCEL_ENV === 'preview'
? process.env.NEXT_PUBLIC_GA_STAGING_ID
: process.env.NEXT_PUBLIC_GA_DEV_ID
// Track environment in events
gtag('set', {
'custom_map': {
'dimension1': 'environment',
'dimension2': 'sanity_dataset'
}
})
gtag('event', 'page_view', {
'environment': process.env.VERCEL_ENV,
'sanity_dataset': process.env.NEXT_PUBLIC_SANITY_DATASET
})
User Properties from Sanity
Track user segments based on Sanity data:
// Set user properties based on content preferences
if (window.gtag && userPreferences) {
window.gtag('set', 'user_properties', {
preferred_content_type: userPreferences.contentType,
subscription_tier: userPreferences.tier,
content_language: userPreferences.locale,
})
}
Consent Mode Implementation
Implement Google Consent Mode for GDPR/CCPA compliance:
// Initialize with denied consent
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'wait_for_update': 500
})
// Update after user consent
function handleConsentUpdate(analyticsConsent: boolean) {
gtag('consent', 'update', {
'analytics_storage': analyticsConsent ? 'granted' : 'denied'
})
}
TypeScript Support
Add type definitions for gtag:
// types/gtag.d.ts
declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'set' | 'consent',
targetId: string,
config?: Record<string, any>
) => void
dataLayer: any[]
}
}
export {}
Testing & Verification
1. Check Real-Time Reports
- Open GA4 → Reports → Realtime
- Navigate your Sanity-powered site
- Verify page views appear within 30 seconds
2. Use GA4 DebugView
Enable debug mode:
gtag('config', GA_MEASUREMENT_ID, {
'debug_mode': true
})
View in GA4:
- Go to Admin → DebugView
- See events with full parameters in real-time
3. Browser Console Testing
// Check if gtag is loaded
console.log(typeof window.gtag) // should be 'function'
// View data layer
console.table(window.dataLayer)
// Test event
window.gtag('event', 'test_event', { test: 'value' })
4. Network Tab Verification
Open Chrome DevTools → Network:
- Filter by
google-analytics.comoranalytics.google.com - Verify
collectrequests are sent - Check request payload for correct parameters
Troubleshooting
GA4 Not Tracking
Issue: No data in GA4 reports.
Checks:
- Measurement ID is correct (starts with
G-) - Script loads successfully (check Network tab)
- No JavaScript errors in console
- Not blocked by ad blocker (test in incognito)
- GA4 property is set up correctly
Server-Side Rendering Issues
Issue: window is not defined error.
Fix: Only access window in client-side code:
// Wrong
const gtag = window.gtag
// Right
if (typeof window !== 'undefined') {
const gtag = window.gtag
}
// Better: Use useEffect in React
useEffect(() => {
window.gtag('event', 'page_view')
}, [])
Events Not Firing on Route Changes
Issue: Only first page view tracked.
Fix: Implement route change tracking (see framework-specific examples above).
Sanity Studio Content Being Tracked
Issue: Draft content tracked in production.
Fix: Check for draft documents and exclude:
const isDraft = sanityDocument._id.startsWith('drafts.')
if (!isDraft) {
// Initialize analytics
}
Performance Optimization
Use Script Loading Strategies
Next.js:
<Script strategy="afterInteractive" /> // Recommended
<Script strategy="lazyOnload" /> // For non-critical tracking
HTML:
<script async src="..." /> // Non-blocking
<script defer src="..." /> // Execute after DOM ready
Minimize Data Layer Size
Only push necessary data:
// Avoid large objects
dataLayer.push({
event: 'content_view',
content: entireSanityDocument // Too large!
})
// Extract only needed fields
dataLayer.push({
event: 'content_view',
content_id: document._id,
content_type: document._type,
title: document.title
})
Next Steps
- Configure GA4 Events - Track custom events
- Set up GTM - For easier tag management
- Troubleshoot Events - Debug tracking issues
For general GA4 concepts, see Google Analytics 4 Guide.