Analytics Architecture on Cosmic
Cosmic (formerly Cosmic JS) is an API-first headless CMS built around a Bucket/Object data model. Content is stored in Buckets and accessed via REST API or the Cosmic SDK. There is no rendering layer -- all HTML output and analytics implementation happens in the frontend framework consuming the API.
Content delivery flow:
Cosmic REST API (Bucket > Object Types > Objects)
|
v
Frontend Framework (Next.js / React / Vue / Astro)
|
v
HTML Output (where tracking scripts execute)
Key components for analytics:
- REST API -- CRUD operations on Objects within Buckets. Object metadata (title, slug, created_at, modified_at, type_slug) is available for data layer population.
- Cosmic SDK -- JavaScript/Node SDK wrapping the REST API. Simplifies fetching content with type safety.
- Webhooks -- Triggered on Object create, edit, delete, publish, and media upload. Enable server-side analytics events.
- Extensions -- Custom UI panels embedded in the Cosmic dashboard. Can trigger analytics events from the editor interface.
- Object Metafields -- Custom fields on Objects. Store tracking IDs, campaign codes, or UTM parameters directly on content.
Installing Tracking Scripts
Cosmic has no template engine. Script injection is handled by your frontend.
Next.js
// 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>
);
}
React SPA (Vite / CRA)
// index.html
<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>
Vue / Nuxt
// 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');`,
}
]
}
}
});
Data Layer Implementation
Pushing Object Metadata from the Cosmic SDK
Fetch Object fields with the Cosmic SDK and push content metadata to the data layer:
// app/blog/[slug]/page.tsx
import { createBucketClient } from '@cosmicjs/sdk';
const cosmic = createBucketClient({
bucketSlug: process.env.COSMIC_BUCKET_SLUG,
readKey: process.env.COSMIC_READ_KEY
});
export default async function BlogPost({ params }) {
const { object } = await cosmic.objects.findOne({
type: 'posts',
slug: params.slug
}).props('id,title,slug,metadata,created_at,modified_at,type');
return (
<>
<script
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'page_data_ready',
content_type: '${object.type}',
content_id: '${object.id}',
content_title: '${object.title.replace(/'/g, "\\'")}',
content_author: '${object.metadata?.author || "unknown"}',
content_category: '${object.metadata?.category || "uncategorized"}',
content_created: '${object.created_at}',
content_modified: '${object.modified_at}'
});`
}}
/>
{/* render post */}
</>
);
}
Using Metafields for Campaign Tracking
Add custom metafields to your Object Type for tracking parameters:
{
"key": "campaign_id",
"type": "text",
"title": "Campaign ID"
},
{
"key": "utm_source",
"type": "text",
"title": "UTM Source"
}
Then include them in your data layer:
window.dataLayer.push({
campaign_id: object.metadata?.campaign_id,
utm_source: object.metadata?.utm_source
});
Server-Side Events via Webhooks
Configure webhooks in Cosmic Dashboard > Bucket Settings > Webhooks:
Endpoint: https://yoursite.com/api/cosmic-webhook
Events: object.created.published, object.edited.published, object.deleted
Webhook handler:
// app/api/cosmic-webhook/route.ts
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.json();
const { type, data } = body;
// Verify webhook (Cosmic sends a webhook_secret if configured)
const secret = req.headers.get('x-cosmic-webhook-secret');
if (secret !== process.env.COSMIC_WEBHOOK_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=SECRET`,
{
method: 'POST',
body: JSON.stringify({
client_id: 'cosmic-cms',
events: [{
name: 'content_event',
params: {
action: type,
content_type: data.type_slug,
content_id: data.id,
content_title: data.title
}
}]
})
}
);
return NextResponse.json({ ok: true });
}
Common Issues
.props() not returning needed fields
The Cosmic SDK uses .props() to select fields. If you omit a field, it will not appear in the response. Always include metadata and timestamp fields for analytics:
// Missing metadata -- data layer will show undefined
const { object } = await cosmic.objects.findOne({ type: 'posts', slug })
.props('title,slug');
// Correct -- include all analytics-relevant fields
const { object } = await cosmic.objects.findOne({ type: 'posts', slug })
.props('id,title,slug,metadata,created_at,modified_at,type');
Client-side routing not firing page views
React SPAs using React Router or Next.js client-side navigation skip full page loads. Implement a route change listener:
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function RouteTracker() {
const location = useLocation();
useEffect(() => {
window.dataLayer?.push({
event: 'page_view',
page_path: location.pathname
});
}, [location.pathname]);
return null;
}
Preview vs. published content
Cosmic Objects have status: 'published' or status: 'draft'. When using ?status=any or ?status=draft for preview, ensure analytics are suppressed:
const isDraft = searchParams.status === 'draft';
// Conditionally load GTM only for published views
Webhook payload size limits
Cosmic webhooks send the full Object payload. For Objects with large metadata or media fields, the payload can be substantial. If your webhook handler parses the full body before sending analytics events, set appropriate body size limits in your API route configuration.
Platform-Specific Considerations
Bucket isolation -- Each Cosmic Bucket is a separate content namespace. If you operate multiple Buckets (e.g., staging and production), ensure your frontend only loads analytics scripts when connected to the production Bucket.
Read Key vs. Write Key -- The Read Key is safe to expose client-side for content fetching. The Write Key must remain server-side. Do not embed Write Keys in client-side analytics code.
Image optimization -- Cosmic's media pipeline at imgix.cosmicjs.com supports URL-parameter transforms. Like other image CDNs, different transform parameters produce different URLs. This can inflate unique asset counts in network analytics.
Extensions -- Cosmic Extensions are custom UI panels embedded in the dashboard. They run in an iframe with access to the Cosmic SDK. You can build an Extension that displays analytics data alongside content editing, but Extensions cannot inject scripts into the public frontend.
Rate limits -- The REST API enforces rate limits per Bucket. If you run analytics queries against the Cosmic API (e.g., counting published Objects), batch your requests or cache results to avoid hitting limits during high-traffic periods.