Unlike platforms with native data layers (like Shopify), Contentful requires you to build a custom data layer in your frontend application. This guide covers data layer architecture and implementation for Contentful-powered sites.
Data Layer Overview
The data layer is a JavaScript object that stores information about your Contentful content, user interactions, and page state. GTM reads this data to populate variables and trigger tags.
How It Works
- Frontend fetches Contentful content via API
- Application pushes content data to
window.dataLayer - GTM reads data layer and populates variables
- Tags fire with Contentful-specific data
Base Data Layer Structure
Page Load Data Layer
Push on every page load:
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
event: 'page_load',
page: {
type: 'contentful_page',
path: window.location.pathname,
title: document.title,
locale: 'en-US',
environment: process.env.NODE_ENV,
},
user: {
id: null, // Hashed user ID if logged in
type: 'guest', // 'guest', 'member', 'subscriber'
loginState: 'logged_out',
},
contentful: {
spaceId: process.env.CONTENTFUL_SPACE_ID,
environment: process.env.CONTENTFUL_ENVIRONMENT || 'master',
},
})
Content-Specific Data Layer
Push when Contentful content is viewed:
window.dataLayer.push({
event: 'content_view',
content: {
type: entry.sys.contentType.sys.id, // 'blogPost', 'product', etc.
id: entry.sys.id, // Contentful entry ID
title: entry.fields.title,
slug: entry.fields.slug,
category: entry.fields.category || 'uncategorized',
tags: entry.fields.tags || [],
author: {
name: entry.fields.author?.fields.name || 'unknown',
id: entry.fields.author?.sys.id || null,
},
publishDate: entry.sys.createdAt,
updateDate: entry.sys.updatedAt,
locale: entry.sys.locale,
version: entry.sys.revision,
},
})
Framework-Specific Implementations
Next.js (App Router)
Create Data Layer Utility:
// utils/dataLayer.ts
export function pushToDataLayer(data: Record<string, any>) {
if (typeof window !== 'undefined') {
window.dataLayer = window.dataLayer || []
window.dataLayer.push(data)
}
}
export function createContentDataLayer(entry: any) {
return {
event: 'content_view',
content: {
type: entry.sys.contentType.sys.id,
id: entry.sys.id,
title: entry.fields.title,
category: entry.fields.category,
tags: entry.fields.tags?.join(',') || '',
author: entry.fields.author?.fields.name || '',
publishDate: entry.sys.createdAt,
locale: entry.sys.locale,
},
}
}
Use in Component:
// app/blog/[slug]/page.tsx
'use client'
import { useEffect } from 'react'
import { pushToDataLayer, createContentDataLayer } from '@/utils/dataLayer'
export default function BlogPost({ post }) {
useEffect(() => {
pushToDataLayer(createContentDataLayer(post))
}, [post])
return <article>{/* Content */}</article>
}
Track Page Views:
// app/components/PageViewTracker.tsx
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
export function PageViewTracker() {
const pathname = usePathname()
useEffect(() => {
window.dataLayer?.push({
event: 'pageview',
page: {
path: pathname,
title: document.title,
},
})
}, [pathname])
return null
}
Next.js (Pages Router)
// pages/_app.tsx
import { useEffect } from 'react'
import { useRouter } from 'next/router'
export default function App({ Component, pageProps }) {
const router = useRouter()
useEffect(() => {
const handleRouteChange = (url: string) => {
window.dataLayer?.push({
event: 'pageview',
page: {
path: url,
title: document.title,
},
})
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => router.events.off('routeChangeComplete', handleRouteChange)
}, [router.events])
return <Component {...pageProps} />
}
// pages/blog/[slug].tsx
import { useEffect } from 'react'
export default function BlogPost({ post }) {
useEffect(() => {
if (typeof window !== 'undefined' && post) {
window.dataLayer?.push({
event: 'content_view',
content: {
type: post.sys.contentType.sys.id,
id: post.sys.id,
title: post.fields.title,
},
})
}
}, [post])
return <article>{/* Content */}</article>
}
Gatsby
Create Data Layer Plugin:
// gatsby-browser.js
export const location, prevLocation }) => {
if (typeof window !== 'undefined' && window.dataLayer) {
window.dataLayer.push({
event: 'pageview',
page: {
path: location.pathname,
title: document.title,
},
})
}
}
// Template: src/templates/blog-post.js
import React, { useEffect } from 'react'
const BlogPostTemplate = ({ data }) => {
const post = data.contentfulBlogPost
useEffect(() => {
if (typeof window !== 'undefined' && window.dataLayer) {
window.dataLayer.push({
event: 'content_view',
content: {
type: 'blogPost',
id: post.contentful_id,
title: post.title,
category: post.category?.name || 'uncategorized',
tags: post.tags?.map(t => t.name).join(',') || '',
author: post.author?.name || '',
publishDate: post.createdAt,
},
})
}
}, [post])
return <article>{/* Content */}</article>
}
export default BlogPostTemplate
React SPA
// App.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
function App() {
const location = useLocation()
useEffect(() => {
window.dataLayer?.push({
event: 'pageview',
page: {
path: location.pathname,
title: document.title,
},
})
}, [location])
return <Router>{/* Routes */}</Router>
}
// ContentPage.tsx
export function ContentPage({ contentfulEntry }) {
useEffect(() => {
window.dataLayer?.push({
event: 'content_view',
content: {
type: contentfulEntry.sys.contentType.sys.id,
id: contentfulEntry.sys.id,
title: contentfulEntry.fields.title,
},
})
}, [contentfulEntry])
return <div>{/* Content */}</div>
}
Data Layer Events
Standard Events
page_load - Initial page load:
{
event: 'page_load',
page: {
type: 'contentful_page',
path: '/blog/example-post',
title: 'Example Post - My Site',
locale: 'en-US',
},
}
pageview - Client-side navigation (SPA):
{
event: 'pageview',
page: {
path: '/blog/new-post',
title: 'New Post - My Site',
},
}
content_view - Contentful content viewed:
{
event: 'content_view',
content: {
type: 'blogPost',
id: '5KsDBWseXY6QegucYAoacS',
title: 'Getting Started with Contentful',
category: 'tutorial',
},
}
user_interaction - User actions:
{
event: 'user_interaction',
interaction: {
type: 'click',
element: 'cta_button',
text: 'Subscribe Now',
destination: '/subscribe',
},
}
search - Content search:
{
event: 'search',
search: {
term: 'contentful tutorial',
resultsCount: 12,
contentTypes: ['blogPost', 'tutorial'],
},
}
Custom Events
newsletter_signup:
{
event: 'newsletter_signup',
newsletter: {
source: 'blog_footer',
contentId: currentContentId,
userType: 'new_subscriber',
},
}
content_engagement:
{
event: 'content_engagement',
engagement: {
contentId: contentId,
contentType: contentType,
timeOnPage: 120, // seconds
scrollDepth: 75, // percentage
completionRate: 0.75, // 0-1
},
}
file_download:
{
event: 'file_download',
file: {
name: 'contentful-guide.pdf',
url: '//assets.ctfassets.net/...',
size: 1024000, // bytes
type: 'pdf',
contentId: contentId,
},
}
Creating GTM Variables
Data Layer Variables
Content Type:
- GTM → Variables → New
- Variable Type: Data Layer Variable
- Data Layer Variable Name:
content.type - Name:
DLV - Content Type
Content ID:
- Data Layer Variable Name:
content.id - Name:
DLV - Content ID
Content Title:
- Data Layer Variable Name:
content.title - Name:
DLV - Content Title
Content Category:
- Data Layer Variable Name:
content.category - Name:
DLV - Content Category
Content Tags:
- Data Layer Variable Name:
content.tags - Name:
DLV - Content Tags
Author Name:
- Data Layer Variable Name:
content.author.name - Name:
DLV - Author Name
Custom JavaScript Variables
Get Contentful Entry ID from URL:
function() {
// Assuming URL format: /blog/{slug}
// And you store entry ID in data layer
const dl = window.dataLayer || []
for (let i = dl.length - 1; i >= 0; i--) {
if (dl[i].content && dl[i].content.id) {
return dl[i].content.id
}
}
return 'unknown'
}
Get Content Type Array:
function() {
const dl = window.dataLayer || []
const contentTypes = new Set()
dl.forEach(obj => {
if (obj.content && obj.content.type) {
contentTypes.add(obj.content.type)
}
})
return Array.from(contentTypes).join(',')
}
Calculate Content Age:
function() {
const dl = window.dataLayer || []
for (let i = dl.length - 1; i >= 0; i--) {
if (dl[i].content && dl[i].content.publishDate) {
const published = new Date(dl[i].content.publishDate)
const now = new Date()
const days = Math.floor((now - published) / (1000 * 60 * 60 * 24))
return days
}
}
return null
}
Lookup Tables
Content Type Display Name:
- Variable Type: Lookup Table
- Input Variable:
\{\{DLV - Content Type\}\} - Add rows:
blogPost→Blog Postproduct→ProductlandingPage→Landing Page
- Default:
Unknown Content Type - Name:
Lookup - Content Type Name
Creating GTM Triggers
Content View Trigger
- Triggers → New
- Trigger Type: Custom Event
- Event name:
content_view - Name:
CE - Content View
Specific Content Type Trigger
- Trigger Type: Custom Event
- Event name:
content_view - Some Custom Events
- Condition:
\{\{DLV - Content Type\}\}equalsblogPost - Name:
CE - Blog Post View
Content Engagement Trigger
- Trigger Type: Custom Event
- Event name:
content_engagement - Name:
CE - Content Engagement
Search Trigger
- Trigger Type: Custom Event
- Event name:
search - Name:
CE - Search
Advanced Data Layer Patterns
Rich Content Data
Include more Contentful metadata:
{
event: 'content_view',
content: {
// Basic info
type: entry.sys.contentType.sys.id,
id: entry.sys.id,
title: entry.fields.title,
// Metadata
category: entry.fields.category,
tags: entry.fields.tags?.map(t => t.fields.title),
// SEO
metaTitle: entry.fields.metaTitle,
metaDescription: entry.fields.metaDescription,
// Content structure
hasFeaturedImage: !!entry.fields.featuredImage,
wordCount: calculateWordCount(entry.fields.body),
readingTime: calculateReadingTime(entry.fields.body),
// Relationships
relatedContent: entry.fields.relatedPosts?.map(p => p.sys.id),
references: entry.fields.references?.map(r => ({
type: r.sys.contentType.sys.id,
id: r.sys.id,
})),
// System info
locale: entry.sys.locale,
version: entry.sys.revision,
createdAt: entry.sys.createdAt,
updatedAt: entry.sys.updatedAt,
publishedAt: entry.fields.publishDate,
},
}
E-commerce Data (Contentful + Commerce)
If using Contentful for product data:
{
event: 'view_item',
ecommerce: {
currency: 'USD',
value: product.fields.price,
items: [{
item_id: product.fields.sku || product.sys.id,
item_name: product.fields.title,
item_brand: product.fields.brand,
item_category: product.fields.category,
item_category2: product.fields.subcategory,
price: product.fields.price,
// Contentful-specific
contentful_id: product.sys.id,
content_type: product.sys.contentType.sys.id,
}],
},
}
User Segment Data
Track user preferences based on content consumption:
{
event: 'user_segment_update',
user: {
segments: ['blog_reader', 'tutorial_consumer'],
preferredCategories: ['development', 'marketing'],
contentEngagement: 'high', // 'low', 'medium', 'high'
visitCount: 5,
lastVisit: '2024-01-15T10:30:00Z',
},
}
A/B Test Data
Track content experiments:
{
event: 'content_experiment',
experiment: {
id: 'headline-test-123',
name: 'Headline Variation Test',
variant: 'B',
contentId: entry.sys.id,
},
}
Data Layer Best Practices
1. Initialize Early
Initialize data layer before GTM:
<script>
window.dataLayer = window.dataLayer || [];
</script>
<!-- GTM script -->
2. Clear Event Data
Clear event-specific data after pushing:
// Push event
window.dataLayer.push({
event: 'content_view',
content: {
type: 'blogPost',
id: '123',
},
})
// Clear for next event (optional, GTM handles this)
window.dataLayer.push({
content: undefined,
})
3. Avoid PII
Never push personally identifiable information:
// Bad
window.dataLayer.push({
user: {
email: 'user@example.com', // NO
name: 'John Doe', // NO
phone: '555-1234', // NO
},
})
// Good
window.dataLayer.push({
user: {
id: hashUserId(user.id), // Hashed
type: 'subscriber',
segment: 'premium',
},
})
4. Consistent Naming
Use camelCase and consistent structure:
// Good
{
contentType: 'blogPost',
contentId: '123',
publishDate: '2024-01-15',
}
// Inconsistent
{
ContentType: 'blogPost', // Mixed case
content_id: '123', // Snake case
publish_date: '2024-01-15',
}
5. Validate Data
Ensure data exists before pushing:
function pushContentView(entry) {
if (!entry || !entry.sys || !entry.fields) {
console.error('Invalid Contentful entry')
return
}
window.dataLayer?.push({
event: 'content_view',
content: {
type: entry.sys.contentType.sys.id,
id: entry.sys.id,
title: entry.fields.title || 'Untitled',
},
})
}
Debugging Data Layer
Console Commands
View entire data layer:
console.table(window.dataLayer)
Find specific events:
window.dataLayer.filter(obj => obj.event === 'content_view')
Monitor new pushes:
const originalPush = window.dataLayer.push
window.dataLayer.push = function() {
console.log('DataLayer Push:', arguments[0])
return originalPush.apply(window.dataLayer, arguments)
}
GTM Preview Mode
- Enable Preview in GTM
- Navigate to your site
- Click Data Layer tab in Tag Assistant
- Verify all Contentful data populates correctly
Browser Extensions
- dataLayer Inspector - Chrome extension
- Tag Assistant Legacy - View data layer in real-time
Common Issues
Data Layer Not Populating
Issue: Variables return undefined in GTM.
Checks:
- Data layer pushed before GTM reads it
- Variable names match exactly (
content.typenotcontentType) - Data exists in Contentful entry
- No JavaScript errors preventing push
Duplicate Events
Issue: Events fire multiple times.
Causes:
- Multiple useEffect calls
- Component re-renders
- Multiple data layer pushes
Fix:
// Use dependency array to prevent duplicate pushes
useEffect(() => {
window.dataLayer?.push(data)
}, [entry.sys.id]) // Only when ID changes
Missing Data on Navigation
Issue: Data layer clears on SPA navigation.
Fix: Push data on every route change (see framework examples above).
Next Steps
- Install GTM - Set up GTM container
- GA4 Events - Use data layer for GA4
- Troubleshoot Events - Debug tracking issues
For general data layer concepts, see Data Layer Guide.