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.