Install Google Analytics 4 with Sanity | Blue Frog Docs

Install Google Analytics 4 with Sanity

How to install GA4 on websites and applications powered by Sanity headless CMS using various frontend frameworks.

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

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

In your Sanity page template:

// 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 ManageAPIWebhooks, 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,
  })
}

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 → ReportsRealtime
  • 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 AdminDebugView
  • 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.com or analytics.google.com
  • Verify collect requests 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

For general GA4 concepts, see Google Analytics 4 Guide.

// SYS.FOOTER