Analytics Implementation on DatoCMS | OpsBlu Docs

Analytics Implementation on DatoCMS

Tracking script installation, data layer setup, and event patterns for DatoCMS sites using GraphQL Content Delivery API and frontend frameworks.

Analytics Architecture on DatoCMS

DatoCMS is a headless CMS that delivers content through a GraphQL Content Delivery API and a REST Content Management API. It renders no HTML. All analytics implementation happens in the frontend framework consuming the API.

Content delivery flow:

DatoCMS Content Delivery API (GraphQL)
    |
    v
Frontend Framework (Next.js / Nuxt / Gatsby / Astro)
    |
    v
HTML Output (where tracking scripts execute)

Key architectural components for analytics:

  • GraphQL Content Delivery API -- Read-only, CDN-backed. Returns structured content with metadata fields usable in the data layer.
  • Structured Text -- DatoCMS's rich text format (based on DAST -- DatoCMS Abstract Syntax Tree). Custom block types within Structured Text can carry tracking attributes.
  • DatoCMS Plugin System -- Sidebar plugins and field extensions run inside the DatoCMS UI. These can trigger external analytics events when editors interact with content.
  • Webhooks -- Fire on record create, update, publish, unpublish, and delete. Enable server-side event tracking.
  • Preview mode -- Real-time preview via draft content endpoint. Must be excluded from production analytics.

Installing Tracking Scripts

Script placement depends on your frontend framework. DatoCMS does not inject any scripts.

Next.js (App Router)

// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <Script
          id="gtm"
          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>{children}</body>
    </html>
  );
}

Nuxt 3

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [
        {
          children: `(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');`,
        }
      ]
    }
  }
});

Gatsby

// gatsby-ssr.js
export function onRenderBody({ setHeadComponents }) {
  setHeadComponents([
    <script
      key="gtm"
      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');`
      }}
    />
  ]);
}

Data Layer Implementation

Pushing Content Metadata from GraphQL Queries

DatoCMS's GraphQL API returns structured metadata. Query the fields you need for tracking, then push them to the data layer:

// app/blog/[slug]/page.tsx
import { performRequest } from '@/lib/datocms';

const QUERY = `
  query PostBySlug($slug: String!) {
    post(filter: { slug: { eq: $slug } }) {
      id
      title
      slug
      _firstPublishedAt
      _publishedAt
      author {
        name
      }
      category {
        name
      }
    }
  }
`;

export default async function BlogPost({ params }) {
  const { post } = await performRequest({ query: QUERY, variables: { slug: params.slug } });

  return (
    <>
      <script
        dangerouslySetInnerHTML={{
          __html: `window.dataLayer = window.dataLayer || [];
          window.dataLayer.push({
            event: 'page_data_ready',
            content_type: 'post',
            content_id: '${post.id}',
            content_title: '${post.title.replace(/'/g, "\\'")}',
            content_author: '${post.author?.name || "unknown"}',
            content_category: '${post.category?.name || "uncategorized"}',
            content_published: '${post._firstPublishedAt}',
            content_updated: '${post._publishedAt}'
          });`
        }}
      />
      {/* render post */}
    </>
  );
}

Structured Text Block Tracking

DatoCMS Structured Text can contain custom blocks. Track interactions with embedded components using the react-datocms renderer:

import { StructuredText } from 'react-datocms';

const renderBlock = ({ record }) => {
  switch (record.__typename) {
    case 'CtaBlockRecord':
      return (
        <button => {
            window.dataLayer?.push({
              event: 'cta_click',
              cta_id: record.id,
              cta_text: record.label,
              cta_location: 'structured_text'
            });
          }}
        >
          {record.label}
        </button>
      );
    case 'VideoBlockRecord':
      return (
        <video
          src={record.videoUrl} => {
            window.dataLayer?.push({
              event: 'video_play',
              video_id: record.id
            });
          }}
        />
      );
    default:
      return null;
  }
};

export function Content({ data }) {
  return <StructuredText data={data} renderBlock={renderBlock} />;
}

Server-Side Events via Webhooks

Configure webhooks in DatoCMS Settings > Webhooks:

URL: https://yoursite.com/api/datocms-webhook
Events: record.publish, record.update, record.delete

Webhook handler:

// app/api/datocms-webhook/route.ts
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const body = await req.json();
  const { event_type, entity } = body;

  // Verify webhook signature
  const signature = req.headers.get('x-datocms-webhook-signature');
  // Validate against your webhook token

  await fetch(
    `https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=SECRET`,
    {
      method: 'POST',
      body: JSON.stringify({
        client_id: 'datocms',
        events: [{
          name: 'content_event',
          params: {
            action: event_type,
            content_type: entity.relationships?.item_type?.data?.id,
            content_id: entity.id
          }
        }]
      })
    }
  );

  return NextResponse.json({ ok: true });
}

DatoCMS Plugin for Editor Analytics

Build a sidebar plugin that tracks editor activity:

// src/entrypoints/EditorTracker.tsx
import { RenderItemFormSidebarPanelCtx } from 'datocms-plugin-sdk';

export function EditorTracker({ ctx }: { ctx: RenderItemFormSidebarPanelCtx }) {
  const handleTrack = () => {
    fetch('/api/track-editor', {
      method: 'POST',
      body: JSON.stringify({
        event: 'editor_save',
        item_type: ctx.itemType.attributes.api_key,
        item_id: ctx.item?.id,
        editor: ctx.currentUserAccessToken
      })
    });
  };

  return <button Edit</button>;
}

Common Issues

Preview mode contaminating production analytics

DatoCMS preview uses the draft content endpoint with includeDrafts: true. If analytics scripts load during preview, draft views inflate production metrics.

// Only load GTM when not in preview
const isPreview = searchParams?.preview === 'true';

{!isPreview && (
  <Script id="gtm" strategy="afterInteractive" src="..." />
)}

Client-side routing drops page views

SPA navigation in Next.js or Nuxt does not trigger full page loads. Track route changes explicitly:

'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';

export function RouteTracker() {
  const pathname = usePathname();
  useEffect(() => {
    window.dataLayer?.push({
      event: 'page_view',
      page_path: pathname
    });
  }, [pathname]);
  return null;
}

GraphQL query not returning metadata fields

DatoCMS auto-generates _firstPublishedAt, _publishedAt, _updatedAt, and _status meta fields. These are not returned unless explicitly requested in your query. If your data layer shows null for publish dates, add these fields to your GraphQL selection.

Webhook payload missing relational data

DatoCMS webhook payloads use JSON API format. Related records are referenced by ID, not expanded inline. To resolve author names or category titles for server-side events, make a follow-up Content Delivery API call:

const authorId = entity.relationships.author.data.id;
const { author } = await performRequest({
  query: `query { author(filter: { id: { eq: "${authorId}" } }) { name } }`
});

Responsive image srcset inflating page view counts

The react-datocms <Image> component generates srcset attributes pointing to DatoCMS's image CDN (www.datocms-assets.com). Different viewport widths load different image URLs. This does not affect page view counts but can inflate asset request metrics in network-level analytics tools.


Platform-Specific Considerations

API rate limits -- The Content Delivery API allows 60 requests/second on the free plan. If running analytics dashboards that query DatoCMS directly, use caching or batch queries to stay within limits.

Environments -- DatoCMS supports sandbox environments (similar to Git branches for content). Each environment has its own Content Delivery API endpoint. Ensure analytics only fire for the primary environment in production builds.

Localization -- DatoCMS supports field-level localization. When tracking content in multiple locales, include the locale in your data layer push to segment analytics by language:

window.dataLayer.push({
  content_locale: params.locale,
  content_title: post.title
});

Build hooks -- DatoCMS can trigger deploy hooks (e.g., Vercel, Netlify) on content publish. These rebuilds change page content but do not inherently update client-side analytics. If you rely on build-time data layer injection, content changes are only reflected after the next build completes.

Web Previews plugin -- The built-in Web Previews plugin opens your frontend in an iframe from the DatoCMS dashboard. Ensure your analytics scripts check for window.self !== window.top if you want to suppress tracking in iframe preview contexts.