Contentful Analytics Implementation Guide | OpsBlu Docs

Contentful Analytics Implementation Guide

Complete guide to implementing analytics with Contentful. Covers frontend framework integration, content model metadata in data layers, Rich Text...

Analytics Architecture with Contentful

Contentful is a headless CMS. It has no frontend, no templates, and no place to inject tracking scripts. All analytics implementation happens in the frontend application that consumes Contentful's Content Delivery API (CDA) or Content Preview API (CPA).

The analytics architecture depends entirely on your frontend framework. Next.js is the most common Contentful frontend, supporting SSG, SSR, and ISR. Gatsby builds static HTML at build time via gatsby-source-contentful, with every production navigation using Gatsby's client-side router. Nuxt (Vue) supports SSR and static generation. Astro fetches data at build time or request time with no client-side routing by default.

The critical architectural point: Contentful's content model fields -- entry IDs, content type IDs, tags, locales, and custom fields -- are your data layer source. Push this metadata to the data layer during page rendering so analytics tools can segment by content attributes.

Contentful's Preview API and published Content Delivery API share the same schema but different endpoints. Your analytics must filter out preview traffic to avoid polluting production data.


Installing Tracking Scripts

Since Contentful has no frontend, scripts go in your framework's layout or entry point. The example below uses Next.js App Router, which is the most common Contentful frontend:

// app/layout.tsx (Next.js App Router)
import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Script
          id="gtm-script"
          strategy="afterInteractive"
          dangerouslySetInnerHTML={{
            __html: `
              (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
              new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
              j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
              'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
              })(window,document,'script','dataLayer','GTM-XXXXXXX');
            `,
          }}
        />
      </head>
      <body>
        <noscript>
          <iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
            height="0" width="0" style={{ display: 'none', visibility: 'hidden' }} />
        </noscript>
        {children}
      </body>
    </html>
  );
}

For Gatsby, use gatsby-ssr.js with setHeadComponents and setPreBodyComponents to inject the GTM container and noscript fallback respectively. For Nuxt 3, add the GTM snippet in nuxt.config.ts under app.head.script. For Astro, place the GTM snippet directly in your base layout's <head>.


Data Layer Setup

Every Contentful entry has a content type, sys metadata (ID, created/updated dates, locale, revision), and your custom fields. Push this metadata into the data layer to enable content-level analytics segmentation.

Content Entry Tracking Utility

// lib/analytics.ts
import { Entry } from 'contentful';

export function pushContentfulEntry(entry: Entry<any>, extraFields?: Record<string, unknown>) {
  if (typeof window === 'undefined') return;

  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: 'contentful_entry_view',
    content_id: entry.sys.id,
    content_type: entry.sys.contentType.sys.id,
    content_title: entry.fields.title || entry.fields.name || '',
    content_locale: entry.sys.locale || 'en-US',
    content_revision: entry.sys.revision,
    content_created_at: entry.sys.createdAt,
    content_updated_at: entry.sys.updatedAt,
    content_tags: entry.metadata?.tags?.map((t: any) => t.sys.id) || [],
    ...extraFields,
  });
}

Client Component for Page-Level Tracking

// components/ContentfulTracker.tsx
'use client';
import { useEffect } from 'react';
import { Entry } from 'contentful';
import { pushContentfulEntry } from '@/lib/analytics';

export function ContentfulTracker({ entry, pageType }: { entry: Entry<any>; pageType: string }) {
  useEffect(() => {
    pushContentfulEntry(entry, { page_type: pageType });
  }, [entry.sys.id]);

  return null;
}

Use <ContentfulTracker entry={post} pageType="blog_post" /> in any page component that renders a Contentful entry.

Category, Author, and Tag References

Contentful entries often reference other entries. Resolve references and push them:

// lib/analytics.ts
export function pushContentfulBlogData(post: Entry<any>) {
  if (typeof window === 'undefined') return;

  const category = post.fields.category;
  const author = post.fields.author;

  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: 'blog_post_view',
    post_id: post.sys.id,
    post_title: post.fields.title,
    post_slug: post.fields.slug,
    post_category: category ? category.fields.name : 'uncategorized',
    post_author: author ? author.fields.name : 'unknown',
    post_published_date: post.fields.publishDate || post.sys.createdAt,
    post_tags: (post.fields.tags || []).map((t: Entry<any>) => t.fields.name),
  });
}

SPA Route Change Tracking

All Contentful frontends that use client-side routing need virtual page view tracking. Add this component to your root layout:

// components/RouteChangeTracker.tsx (Next.js App Router)
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';

export function RouteChangeTracker() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const isFirstLoad = useRef(true);

  useEffect(() => {
    if (isFirstLoad.current) {
      isFirstLoad.current = false;
      return;
    }
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'virtual_page_view',
      page_path: pathname + (searchParams.toString() ? '?' + searchParams.toString() : ''),
      page_title: document.title,
    });
  }, [pathname, searchParams]);

  return null;
}

For Gatsby, use onRouteUpdate in gatsby-browser.js:

// gatsby-browser.js
export const location }) => {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: 'virtual_page_view',
    page_path: location.pathname + location.search,
    page_title: document.title,
  });
};

For Nuxt 3, create a client plugin using router.afterEach() to push on each navigation.


Filtering Preview API Traffic

Contentful's Preview API lets editors see draft content. If your preview environment shares the same analytics property as production, preview traffic will pollute your data:

// lib/analytics.ts
export function isPreviewMode(): boolean {
  if (typeof window === 'undefined') return false;
  return (
    window.location.hostname.includes('preview') ||
    window.location.search.includes('preview=true') ||
    document.cookie.includes('__next_preview_data') ||
    process.env.NEXT_PUBLIC_CONTENTFUL_PREVIEW === 'true'
  );
}

export function pushDataLayer(data: Record<string, unknown>) {
  if (isPreviewMode()) {
    console.debug('[Analytics] Preview mode - skipping dataLayer push:', data);
    return;
  }
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push(data);
}

Alternatively, push a flag and filter in GTM. Add is_preview: process.env.NEXT_PUBLIC_CONTENTFUL_PREVIEW === 'true' to your initial data layer push, then create an exception trigger in GTM where Data Layer Variable is_preview equals true.


Common Errors

Error Cause Fix
Data layer pushes fire with empty content fields Entry data is fetched asynchronously and the tracking code runs before the API response resolves Move dataLayer.push inside the .then() callback or useEffect that depends on the fetched entry data
Preview traffic inflates production page views Preview and production environments use the same GA4 property without filtering Add an is_preview flag to the data layer and create an exception trigger in GTM, or use separate GA4 properties
Content type IDs in data layer are machine names Contentful sys.contentType.sys.id returns the API identifier (e.g., blogPost), not the display name Map content type IDs to human-readable names in your analytics utility, or query the Content Type API to resolve display names
Linked entries show as unresolved Link objects The Contentful API call did not include the include parameter to resolve references Add include: 3 (or appropriate depth) to your API query to resolve linked entries before pushing to the data layer
Duplicate virtual page views on initial load Route change listener fires on first client-side mount in addition to the server-rendered page view Use a ref flag (isFirstLoad) to skip the first useEffect execution, as shown in the route change tracking example
Rich Text word count returns 0 The word count calculation does not traverse nested Rich Text nodes beyond the first level Use documentToPlainTextString from @contentful/rich-text-plain-text-renderer to extract all text before counting
Localized entries push the wrong locale The sys.locale field reflects the requested locale, but locale: '*' returns all locales in one response Extract the active locale from your router or i18n context rather than from the entry's sys object
SSG pages have stale data layer values Static pages were built with old Contentful data and ISR has not revalidated Set revalidate to an appropriate interval in Next.js, or use on-demand revalidation triggered by Contentful webhooks
GA4 events show (not set) for custom dimensions The data layer variable names in GTM do not match the exact keys in dataLayer.push() Verify that GTM data layer variable names match your push keys exactly, including case sensitivity
Asset URLs lack the https: protocol prefix entry.fields.image.fields.file.url returns //images.ctfassets.net/... as a protocol-relative URL Prepend https: to Contentful asset URLs before pushing to the data layer

Performance Considerations

  • Use next/script with strategy="afterInteractive" for GTM in Next.js. This ensures GTM loads after the page becomes interactive rather than blocking hydration. Avoid beforeInteractive for analytics scripts.

  • Batch Contentful API calls to minimize request waterfalls. If a page needs entries from multiple content types, use a single getEntries call with content_type filtering and include: 3 rather than multiple sequential calls.

  • Static generation (SSG/ISR) eliminates runtime API latency. Pages built at build time have all Contentful data embedded in the HTML, so data layer values are available immediately without client-side API calls.

  • Avoid fetching full entries just for analytics metadata. Use the select parameter to request only needed fields: select: 'sys.id,sys.contentType,fields.title,fields.slug'. This reduces response payload size significantly.

  • Rich Text rendering can be expensive on large documents. If you calculate word counts or extract headings for the data layer, cache these computations during data fetching rather than running them on every render.

  • Contentful's CDN delivers API responses from edge locations. For SSR pages, use the Content Delivery API (cdn.contentful.com) and not the Management API to minimize response time.