Analytics Architecture on Decap CMS
Decap CMS (formerly Netlify CMS) is an open-source, Git-based CMS that provides a browser-based editing interface for content stored as flat files (Markdown, YAML, JSON) in a Git repository. It has no content API and no server-side rendering. Content is consumed at build time by static site generators (Gatsby, Hugo, Jekyll, Next.js, Eleventy) that produce the final HTML.
Content delivery flow:
Git Repository (Markdown / YAML / JSON files)
|
v
Static Site Generator (Gatsby / Hugo / Jekyll / Eleventy / Next.js)
|
v
Static HTML Output (where tracking scripts execute)
|
v
CDN (Netlify / Vercel / Cloudflare Pages)
Key components for analytics:
- Git Gateway -- Authentication proxy between the CMS editor and the Git repository. Powered by Netlify Identity or a custom backend. Editor activity goes through Git Gateway, creating commits.
- config.yml -- Central configuration file defining collections, fields, and editorial workflow. Determines what content structure is available for tracking metadata.
- Editorial Workflow -- Optional draft/review/ready workflow that creates pull requests for content changes. PR-based workflow enables webhook-driven analytics on content lifecycle events.
- Custom Widgets -- Extend the editor UI with custom field types. Can include tracking-specific fields.
- Event Handlers -- Decap CMS emits JavaScript events during the editing lifecycle (
preSave,postSave). These can trigger client-side analytics from the editor.
Installing Tracking Scripts
Since Decap CMS generates static files, tracking script placement depends on your static site generator.
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');`
}}
/>
]);
}
Hugo
<!-- layouts/partials/head.html -->
<script>
(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');
</script>
Jekyll
<!-- _includes/head.html -->
<script>
(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');
</script>
Eleventy (11ty)
<!-- _includes/base.njk -->
<head>
<script>
(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');
</script>
</head>
Next.js (Static Export)
// 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>
);
}
Data Layer Implementation
Injecting Frontmatter Metadata at Build Time
Decap CMS stores content metadata in frontmatter. Your static site generator reads this at build time and can inject it into the data layer.
// src/templates/blog-post.tsx
export default function BlogPost({ data }) {
const post = data.markdownRemark;
return (
<>
<script
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_data_ready',
content_type: 'blog_post',
content_id: '${post.fields.slug}',
content_title: '${post.frontmatter.title.replace(/'/g, "\\'")}',
content_author: '${post.frontmatter.author || "unknown"}',
content_category: '${post.frontmatter.category || "uncategorized"}',
content_date: '${post.frontmatter.date}',
content_tags: '${(post.frontmatter.tags || []).join(",")}'
});`
}}
/>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</>
);
}
export const query = graphql`
query($slug: String!) {
markdownRemark(fields: { slug: { eq: $slug } }) {
html
fields { slug }
frontmatter {
title
date
author
category
tags
}
}
}
`;
Hugo (Go templates):
<!-- layouts/blog/single.html -->
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_data_ready',
content_type: '{{ .Type }}',
content_id: '{{ .File.UniqueID }}',
content_title: '{{ .Title | safeJS }}',
content_author: '{{ .Params.author | default "unknown" }}',
content_category: '{{ index .Params.categories 0 | default "uncategorized" }}',
content_date: '{{ .Date.Format "2006-01-02" }}',
content_tags: '{{ delimit .Params.tags "," }}'
});
</script>
Jekyll (Liquid templates):
<!-- _layouts/post.html -->
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_data_ready',
content_type: 'post',
content_id: '{{ page.url | slugify }}',
content_title: '{{ page.title | escape }}',
content_author: '{{ page.author | default: "unknown" }}',
content_category: '{{ page.categories | first | default: "uncategorized" }}',
content_date: '{{ page.date | date: "%Y-%m-%d" }}',
content_tags: '{{ page.tags | join: "," }}'
});
</script>
Adding Tracking Fields to config.yml
Define analytics-specific fields in your Decap CMS configuration:
# static/admin/config.yml
collections:
- name: blog
label: Blog
folder: content/blog
create: true
fields:
- { label: Title, name: title, widget: string }
- { label: Publish Date, name: date, widget: datetime }
- { label: Author, name: author, widget: string }
- { label: Category, name: category, widget: select,
options: [tech, business, design, marketing] }
- { label: Tags, name: tags, widget: list }
- { label: Body, name: body, widget: markdown }
# Analytics fields
- { label: Campaign ID, name: campaign_id, widget: string, required: false }
- { label: Tracking Group, name: tracking_group, widget: select,
options: [organic, paid, email, social], required: false }
Editor Event Handlers
Decap CMS emits events during the editorial lifecycle. Register handlers to track editor activity:
// static/admin/index.html
<script>
CMS.registerEventListener({
name: 'preSave',
handler: ({ entry }) => {
console.log('Content saving:', entry.get('data').toJS());
// Send to analytics endpoint
fetch('/api/track-editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'content_save',
collection: entry.get('collection'),
slug: entry.get('slug'),
timestamp: new Date().toISOString()
})
});
return entry.get('data');
}
});
</script>
Server-Side Events via Git Webhooks
Since Decap CMS creates Git commits, use Git platform webhooks to track content changes:
GitHub Webhook:
URL: https://yoursite.com/api/content-webhook
Events: push, pull_request (if using editorial workflow)
With the editorial workflow enabled, content goes through a PR-based review process. Track the full lifecycle:
// api/content-webhook.ts
export async function POST(req: Request) {
const body = await req.json();
if (body.action === 'opened' && body.pull_request) {
// Draft content submitted for review
await trackEvent('content_draft_submitted', {
pr_number: body.pull_request.number,
title: body.pull_request.title
});
}
if (body.action === 'closed' && body.pull_request?.merged) {
// Content published (PR merged)
await trackEvent('content_published', {
pr_number: body.pull_request.number,
files_changed: body.pull_request.changed_files
});
}
}
Common Issues
Static site stale data layer
Decap CMS sites are statically generated. The data layer is baked into the HTML at build time. If content changes but the site has not been rebuilt, the data layer contains stale metadata. Configure automatic deploys on Git push:
# netlify.toml
[build]
command = "npm run build"
publish = "public"
[build.environment]
NODE_ENV = "production"
Most hosting platforms (Netlify, Vercel, Cloudflare Pages) automatically rebuild on Git push, which is triggered by Decap CMS saves.
Admin panel analytics contamination
The Decap CMS admin UI is served from /admin/ on your site. If your GTM container or analytics scripts are included in the base layout, they fire on the admin pages. Exclude the admin path:
// In GTM, add a trigger exception:
// Page Path does not match /admin/*
// Or in your template:
if (!window.location.pathname.startsWith('/admin')) {
// Load analytics
}
Netlify Identity sessions inflating user counts
If you use Netlify Identity for CMS authentication, logged-in editors have identity cookies. Analytics platforms may count editor sessions separately from anonymous visitors. Use the identity state to tag editor sessions:
if (window.netlifyIdentity?.currentUser()) {
window.dataLayer.push({ user_role: 'editor' });
}
Editorial workflow PRs creating preview deploys
When the editorial workflow is enabled, Decap CMS creates branches and pull requests for draft content. Hosting platforms that create deploy previews for PRs will generate separate URLs with analytics scripts. These preview deploys can inflate page view counts.
Use separate GTM containers or environment variables for preview vs. production:
// Check deploy context
const isPreview = window.location.hostname.includes('deploy-preview');
if (!isPreview) {
// Load production analytics
}
Custom widgets not persisting tracking fields
Custom Decap CMS widgets must implement toJSON() to persist field values. If a custom tracking field widget does not serialize correctly, the field value will be lost on save and will not appear in the built output.
Platform-Specific Considerations
Build-time-only data -- Unlike API-based headless CMSs, Decap CMS content is only available at build time (for SSG) or from the filesystem (for SSR). There is no runtime API to query for analytics enrichment. All content metadata must be injected during the build.
Git Gateway latency -- Git Gateway proxies CMS saves through Netlify's infrastructure to the Git provider. There is a delay between an editor clicking "Publish" and the commit appearing in the repository. Deploy hooks triggered by the commit will fire after this delay, meaning analytics events for "content published" may lag behind the editor's action.
Decap CMS vs. Netlify CMS naming -- The project was renamed from Netlify CMS to Decap CMS in February 2023. Package names changed from netlify-cms to decap-cms. Both npm packages exist. Ensure you are importing from decap-cms-app for current versions.
config.yml location -- The CMS configuration file is typically at static/admin/config.yml (Gatsby), static/admin/config.yml (Hugo), or public/admin/config.yml (generic). This file is served publicly. Do not include API keys or tracking secrets in it.
Media handling -- Decap CMS stores media files in the Git repository (default) or in an external media library (Cloudinary, Uploadcare). Git-stored media increases repository size, which can slow builds and delay analytics data freshness. External media libraries serve assets from their own CDN, producing separate domain entries in network analytics.
Identity widget -- The Netlify Identity widget (netlify-identity-widget) adds its own JavaScript to the page. This script makes network requests to Netlify's identity service. These requests appear in network analytics and should be filtered out of meaningful traffic analysis.