Data Layer Structure for Contentful + GTM | OpsBlu Docs

Data Layer Structure for Contentful + GTM

Complete reference for implementing a custom data layer for Contentful content with Google Tag Manager.

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

  1. Frontend fetches Contentful content via API
  2. Application pushes content data to window.dataLayer
  3. GTM reads data layer and populates variables
  4. 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:

  1. GTM → VariablesNew
  2. Variable Type: Data Layer Variable
  3. Data Layer Variable Name: content.type
  4. 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:

  1. Variable Type: Lookup Table
  2. Input Variable: \{\{DLV - Content Type\}\}
  3. Add rows:
    • blogPostBlog Post
    • productProduct
    • landingPageLanding Page
  4. Default: Unknown Content Type
  5. Name: Lookup - Content Type Name

Creating GTM Triggers

Content View Trigger

  1. TriggersNew
  2. Trigger Type: Custom Event
  3. Event name: content_view
  4. Name: CE - Content View

Specific Content Type Trigger

  1. Trigger Type: Custom Event
  2. Event name: content_view
  3. Some Custom Events
  4. Condition: \{\{DLV - Content Type\}\} equals blogPost
  5. Name: CE - Blog Post View

Content Engagement Trigger

  1. Trigger Type: Custom Event
  2. Event name: content_engagement
  3. Name: CE - Content Engagement

Search Trigger

  1. Trigger Type: Custom Event
  2. Event name: search
  3. 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

  1. Enable Preview in GTM
  2. Navigate to your site
  3. Click Data Layer tab in Tag Assistant
  4. 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.type not contentType)
  • 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

For general data layer concepts, see Data Layer Guide.