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
Method 1: Next.js (App Router) - Recommended for New Projects
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,
})
}
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 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 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).
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:
- Go to Admin → Custom Definitions → Create custom dimensions
- 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
- 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 Overview.