Install Google Analytics 4 with Contentful | OpsBlu Docs

Install Google Analytics 4 with Contentful

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

Since Contentful is a headless CMS, GA4 is installed in your frontend application, not in Contentful 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)
  • Contentful content integrated into your frontend application
  • Developer access to your frontend codebase
  • Understanding of your framework's structure

Important: Contentful only stores and delivers content. All analytics code lives in your frontend framework.

Implementation by Framework

Next.js 13+ with App Router is the recommended approach for modern Contentful 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 Contentful Content Views

In your Contentful content pages:

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

export default async function BlogPost({ params }) {
  const post = await getContentfulPost(params.slug)

  return (
    <div>
      <ContentTracker
        contentType="blog_post"
        contentId={post.sys.id}
        title={post.fields.title}
        category={post.fields.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 Contentful 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-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

4. Track Contentful Content

In your Contentful page template:

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

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

  useEffect(() => {
    if (typeof window !== 'undefined' && window.gtag) {
      window.gtag('event', 'content_view', {
        content_type: 'blog_post',
        content_id: post.contentful_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 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 Contentful 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 Contentful Content

<template>
  <article>
    <h1>{{ post.fields.title }}</h1>
    <!-- Content -->
  </article>
</template>

<script>
export default {
  async asyncData({ $contentful, params }) {
    const post = await $contentful.getEntry(params.id)
    return { post }
  },

  mounted() {
    if (this.$ga) {
      this.$ga.event('content_view', {
        content_type: this.post.sys.contentType.sys.id,
        content_id: this.post.sys.id,
        title: this.post.fields.title,
      })
    }
  },
}
</script>

Method 5: React SPA (Vite, Create React App)

For single-page applications consuming Contentful.

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 Contentful content types perform best:

export function trackContentfulContent(entry: any) {
  if (!window.gtag) return

  window.gtag('event', 'content_view', {
    content_type: entry.sys.contentType.sys.id,
    content_id: entry.sys.id,
    title: entry.fields.title,
    category: entry.fields.category,
    tags: entry.fields.tags?.join(','),
    author: entry.fields.author?.fields.name,
    publish_date: entry.sys.createdAt,
    locale: entry.sys.locale,
  })
}

Preview Mode Exclusion

Don't track Contentful preview sessions:

const isPreview = searchParams.get('preview') === 'true' ||
                  searchParams.get('contentful_preview') === 'true'

if (!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': 'contentful_env'
  }
})

gtag('event', 'page_view', {
  'environment': process.env.VERCEL_ENV,
  'contentful_env': process.env.CONTENTFUL_ENVIRONMENT
})

User Properties from Contentful

Track user segments based on Contentful 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 Contentful-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).

Preview Content Being Tracked

Issue: Contentful preview sessions tracked in production.

Fix: Check for preview mode and exclude:

const isPreview = router.query.preview === 'true'
if (!isPreview) {
  // 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: entireContentfulEntry // Too large!
})

// Extract only needed fields
dataLayer.push({
  event: 'content_view',
  content_id: entry.sys.id,
  content_type: entry.sys.contentType.sys.id,
  title: entry.fields.title
})

Contentful Content Model Integration

Tracking Content Model Metadata

Leverage Contentful's structured content to enrich your analytics:

// utils/contentful-analytics.ts
export interface ContentfulAnalyticsData {
  contentType: string
  contentId: string
  title: string
  category?: string
  tags?: string[]
  author?: string
  publishDate?: string
  locale?: string
  space?: string
  environment?: string
}

export function trackContentfulEntry(entry: any): ContentfulAnalyticsData {
  return {
    contentType: entry.sys.contentType.sys.id,
    contentId: entry.sys.id,
    title: entry.fields.title || 'Untitled',
    category: entry.fields.category,
    tags: entry.fields.tags || [],
    author: entry.fields.author?.fields.name,
    publishDate: entry.sys.createdAt,
    locale: entry.sys.locale,
    space: entry.sys.space?.sys.id,
    environment: entry.sys.environment?.sys.id,
  }
}

export function sendContentViewToGA4(entry: any) {
  const data = trackContentfulEntry(entry)

  if (window.gtag) {
    window.gtag('event', 'content_view', {
      content_type: data.contentType,
      content_id: data.contentId,
      title: data.title,
      category: data.category,
      tags: data.tags?.join(','),
      author: data.author,
      publish_date: data.publishDate,
      locale: data.locale,
    })
  }
}

Custom Dimensions for Contentful Data

Set up custom dimensions in GA4 to capture Contentful-specific metadata:

GA4 Configuration:

  1. Go to AdminCustom DefinitionsCreate custom dimensions
  2. Add these dimensions:
Dimension Name Event Parameter Scope
Content Type content_type Event
Content ID content_id Event
Content Category category Event
Content Tags tags Event
Content Author author Event
Content Locale locale Event
Contentful Space space_id Event
Contentful Environment environment Event

Implementation:

// Send custom dimensions with page view
gtag('event', 'page_view', {
  content_type: 'blogPost',
  content_id: entry.sys.id,
  category: entry.fields.category,
  tags: entry.fields.tags?.join(','),
  author: entry.fields.author?.fields.name,
  locale: entry.sys.locale,
  space_id: entry.sys.space.sys.id,
  environment: entry.sys.environment?.sys.id,
})

Multi-Locale Tracking

Track content across different Contentful locales:

'use client'

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

export function LocaleTracker({ entry }) {
  const params = useParams()
  const locale = params.locale || 'en-US'

  useEffect(() => {
    if (window.gtag) {
      // Set user property for preferred locale
      window.gtag('set', 'user_properties', {
        preferred_locale: locale,
      })

      // Track content view with locale
      window.gtag('event', 'content_view', {
        content_id: entry.sys.id,
        locale: locale,
        available_locales: Object.keys(entry.fields.title || {}).join(','),
      })
    }
  }, [entry, locale])

  return null
}

Reference Field Tracking

Track relationships between Contentful entries:

export function trackContentRelationships(entry: any) {
  // Track linked entries (references)
  const linkedEntries = extractLinkedEntries(entry)

  if (window.gtag && linkedEntries.length > 0) {
    window.gtag('event', 'content_with_references', {
      content_id: entry.sys.id,
      content_type: entry.sys.contentType.sys.id,
      reference_count: linkedEntries.length,
      referenced_types: linkedEntries
        .map(e => e.sys.contentType.sys.id)
        .join(','),
    })
  }
}

function extractLinkedEntries(entry: any): any[] {
  const linked: any[] = []

  Object.values(entry.fields).forEach((value: any) => {
    if (value?.sys?.type === 'Entry') {
      linked.push(value)
    } else if (Array.isArray(value)) {
      value.forEach(item => {
        if (item?.sys?.type === 'Entry') {
          linked.push(item)
        }
      })
    }
  })

  return linked
}

Rich Text Field Engagement

Track engagement with Contentful rich text content:

import { documentToPlainTextString } from '@contentful/rich-text-plain-text-renderer'

export function trackRichTextEngagement(richTextField: any, entryId: string) {
  // Calculate reading time
  const plainText = documentToPlainTextString(richTextField)
  const wordCount = plainText.split(/\s+/).length
  const readingTimeMinutes = Math.ceil(wordCount / 200) // Average reading speed

  if (window.gtag) {
    window.gtag('event', 'content_metrics', {
      content_id: entryId,
      word_count: wordCount,
      estimated_reading_time: readingTimeMinutes,
      has_images: richTextField.content.some(
        node => node.nodeType === 'embedded-asset-block'
      ),
      has_embedded_entries: richTextField.content.some(
        node => node.nodeType === 'embedded-entry-block'
      ),
    })
  }
}

Content Type-Specific Tracking

Create specialized tracking for different Contentful content types:

// Track blog posts
export function trackBlogPost(post: any) {
  if (!window.gtag) return

  window.gtag('event', 'blog_post_view', {
    content_id: post.sys.id,
    title: post.fields.title,
    category: post.fields.category,
    author: post.fields.author?.fields.name,
    publish_date: post.fields.publishDate,
    tags: post.fields.tags?.join(','),
    featured: post.fields.featured || false,
  })
}

// Track product pages
export function trackProduct(product: any) {
  if (!window.gtag) return

  window.gtag('event', 'view_item', {
    currency: 'USD',
    value: product.fields.price,
    items: [{
      item_id: product.sys.id,
      item_name: product.fields.name,
      item_category: product.fields.category,
      price: product.fields.price,
    }]
  })
}

// Track landing pages
export function trackLandingPage(page: any) {
  if (!window.gtag) return

  window.gtag('event', 'landing_page_view', {
    content_id: page.sys.id,
    page_type: page.sys.contentType.sys.id,
    campaign: page.fields.campaignId,
    variant: page.fields.variant,
  })
}

Contentful GraphQL Query Tracking

Track which GraphQL queries are used (for optimization):

// lib/contentful-client.ts
const originalFetch = fetch

window.fetch = async function(...args) {
  const response = await originalFetch.apply(this, args)

  // Track Contentful GraphQL queries
  if (args[0]?.toString().includes('graphql.contentful.com')) {
    const clonedResponse = response.clone()
    const body = await clonedResponse.json()

    if (window.gtag && body.data) {
      window.gtag('event', 'contentful_query', {
        query_type: 'graphql',
        entries_fetched: Object.keys(body.data).length,
        has_includes: body.includes ? true : false,
      })
    }
  }

  return response
}

Headless CMS Best Practices

1. Separate Analytics Environments

Use different GA4 properties for different Contentful environments:

const getGA4MeasurementId = () => {
  const contentfulEnv = process.env.NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT

  const envMap: Record<string, string> = {
    'master': process.env.NEXT_PUBLIC_GA4_PRODUCTION!,
    'staging': process.env.NEXT_PUBLIC_GA4_STAGING!,
    'development': process.env.NEXT_PUBLIC_GA4_DEV!,
  }

  return envMap[contentfulEnv] || envMap['development']
}

export function Analytics() {
  const measurementId = getGA4MeasurementId()

  return (
    <Script
      src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
      strategy="afterInteractive"
    />
  )
}

2. Track Content Delivery Network Performance

Monitor Contentful CDN performance:

export function trackContentfulCDNPerformance(entry: any, fetchStartTime: number) {
  const fetchEndTime = performance.now()
  const fetchDuration = fetchEndTime - fetchStartTime

  if (window.gtag) {
    window.gtag('event', 'contentful_performance', {
      content_type: entry.sys.contentType.sys.id,
      fetch_duration_ms: Math.round(fetchDuration),
      cache_hit: entry.sys.revision === 1, // First revision = fresh fetch
    })
  }
}

// Usage
const startTime = performance.now()
const entry = await client.getEntry(entryId)
trackContentfulCDNPerformance(entry, startTime)

3. Client-Side vs Server-Side Rendering Analytics

Handle analytics differently for SSR and CSR:

// For Server-Side Rendered pages
export async function getStaticProps({ params }) {
  const entry = await getContentfulEntry(params.slug)

  return {
    props: {
      entry,
      analyticsData: {
        contentType: entry.sys.contentType.sys.id,
        contentId: entry.sys.id,
        title: entry.fields.title,
        renderType: 'SSR',
      },
    },
  }
}

// In component
export function BlogPost({ entry, analyticsData }) {
  useEffect(() => {
    if (window.gtag) {
      window.gtag('event', 'page_view', {
        ...analyticsData,
        render_type: 'SSR',
        hydration_time: performance.now(),
      })
    }
  }, [analyticsData])

  return <article>...</article>
}

Next Steps

For general GA4 concepts, see Google Analytics 4 Overview.