Analytics Architecture on Sanity
Sanity is a headless CMS. It has no HTML rendering layer. All analytics implementation happens in the frontend framework that consumes the Content Lake API. Sanity Studio runs as a separate React application on a different origin, so Studio activity and public site tracking are fully isolated.
The content delivery pipeline:
Sanity Content Lake (API)
|
v
Frontend Framework (Next.js / Gatsby / Nuxt / SvelteKit)
|
v
HTML Output (where tracking scripts execute)
Sanity provides three content delivery mechanisms relevant to analytics:
- GROQ API -- Query content with projections. The response shape determines what metadata is available for tracking.
- GraphQL API -- Type-safe alternative. Same content, different query syntax.
- Real-time listener -- Websocket subscription to document changes. Useful for tracking content publish events server-side.
Sanity Studio supports Document Actions -- custom toolbar buttons that fire when editors publish, unpublish, or perform custom operations. These can trigger server-side analytics events via webhooks or direct API calls.
Installing Tracking Scripts
Since Sanity has no template system, script injection depends entirely on your frontend framework.
Next.js (App Router)
Add tracking scripts in the root layout:
// 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>
);
}
Next.js (Pages Router)
// pages/_app.tsx
import Script from 'next/script';
export default function App({ Component, pageProps }) {
return (
<>
<Script
id="gtm"
strategy="afterInteractive"
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
/>
<Component {...pageProps} />
</>
);
}
Gatsby
Use gatsby-plugin-google-tagmanager or inject manually in gatsby-ssr.js:
// 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');`
}}
/>
]);
}
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');`,
}
]
}
}
});
Data Layer Implementation
The data layer bridges Sanity content metadata into your analytics. Populate window.dataLayer during page render using data fetched from the Content Lake.
Pushing Content Metadata from GROQ Queries
Fetch content fields you want tracked, then push them before the page renders:
// app/blog/[slug]/page.tsx
import { client } from '@/lib/sanity.client';
const query = `*[_type == "post" && slug.current == $slug][0]{
_id,
title,
"author": author->name,
"category": categories[0]->title,
publishedAt,
_updatedAt
}`;
export default async function BlogPost({ params }) {
const post = await client.fetch(query, { 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}',
content_category: '${post.category || "uncategorized"}',
content_published: '${post.publishedAt}',
content_updated: '${post._updatedAt}'
});`
}}
/>
{/* render post */}
</>
);
}
Tracking Portable Text Engagement
Portable Text renders custom block types. Track interaction with embedded elements by attaching event handlers in your serializer components:
const components = {
types: {
cta: ({ value }) => (
<button => {
window.dataLayer?.push({
event: 'cta_click',
cta_id: value._key,
cta_text: value.label,
cta_location: 'inline_content'
});
}}
>
{value.label}
</button>
),
video: ({ value }) => (
<video
src={value.url} => {
window.dataLayer?.push({
event: 'video_play',
video_id: value._key,
video_title: value.title
});
}}
/>
)
}
};
Server-Side Events via Webhooks
Configure a Sanity webhook to fire when documents are published. This enables server-side event tracking without any frontend involvement:
Sanity Project Settings > API > Webhooks
URL: https://yoursite.com/api/sanity-webhook
Trigger: Create, Update, Delete
Filter: _type == "post"
Webhook handler:
// app/api/sanity-webhook/route.ts
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.json();
const { _type, _id, slug } = body;
// Send server-side event to GA4 Measurement Protocol
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=SECRET`,
{
method: 'POST',
body: JSON.stringify({
client_id: 'sanity-cms',
events: [{
name: 'content_published',
params: {
content_type: _type,
content_id: _id,
slug: slug?.current
}
}]
})
}
);
return NextResponse.json({ ok: true });
}
Document Actions for Editor Analytics
Fire analytics events from Sanity Studio when editors take actions:
// sanity.config.ts
import { defineConfig } from 'sanity';
export default defineConfig({
document: {
actions: (prev, context) => {
return prev.map(action => {
if (action.action === 'publish') {
const originalAction = action;
return {
...originalAction,
onHandle: () => {
// Track publish event
fetch('/api/track', {
method: 'POST',
body: JSON.stringify({
event: 'studio_publish',
document_type: context.schemaType,
document_id: context.documentId
})
});
originalAction.onHandle?.();
}
};
}
return action;
});
}
}
});
Common Issues
Client-side navigation loses tracking
Sanity frontends built with Next.js or Gatsby use client-side routing. Page view events only fire on initial load unless you handle route changes.
Next.js App Router fix:
// app/providers.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
export function AnalyticsProvider({ children }) {
const pathname = usePathname();
useEffect(() => {
window.dataLayer?.push({
event: 'page_view',
page_path: pathname
});
}, [pathname]);
return <>{children}</>;
}
Preview/draft content pollutes production analytics
Sanity's preview mode fetches draft content. If analytics fire during preview, you contaminate production data.
import { draftMode } from 'next/headers';
export default async function Layout({ children }) {
const { isEnabled } = draftMode();
return (
<html>
<head>
{!isEnabled && (
<script
dangerouslySetInnerHTML={{
__html: `/* GTM snippet here */`
}}
/>
)}
</head>
<body>{children}</body>
</html>
);
}
Multi-dataset tracking separation
If you run production and staging datasets, ensure analytics only fire for the production dataset:
const client = createClient({
projectId: 'your-id',
dataset: process.env.SANITY_DATASET,
useCdn: process.env.SANITY_DATASET === 'production'
});
// Only load analytics for production
const isProduction = process.env.SANITY_DATASET === 'production';
GROQ projection missing tracking fields
If your GROQ query does not project the fields needed for the data layer, those values will be undefined. Always explicitly include analytics-relevant fields:
// Bad: spread operator hides what is available
*[_type == "post"][0]{ ... }
// Good: explicit projection for tracking
*[_type == "post"][0]{
_id,
title,
"author": author->name,
"category": categories[0]->title,
publishedAt
}
Platform-Specific Considerations
Real-time listeners and analytics -- Sanity's client.listen() opens a persistent websocket. If you use this for live-updating content on the frontend, ensure page view events do not re-fire on every content update. Debounce or gate analytics pushes.
Content Lake API rate limits -- The free tier allows 100 requests/second. If you are running analytics queries against the Content Lake (e.g., counting published documents), use the CDN endpoint (useCdn: true) to avoid rate limiting.
Image CDN parameters -- Sanity's image pipeline (cdn.sanity.io) supports transformations via URL parameters. When tracking image load performance, note that different w= and q= parameters produce different URLs, which may inflate unique asset counts in your analytics.
Webhook retry behavior -- Sanity retries failed webhook deliveries up to 5 times with exponential backoff. If your webhook handler sends analytics events, implement idempotency to prevent duplicate event recording. Use the _rev field as a deduplication key.
ISR and stale content -- When using Next.js ISR (Incremental Static Regeneration) with Sanity, cached pages may serve stale content metadata to the data layer. Use on-demand revalidation triggered by Sanity webhooks to keep analytics data current:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
export async function POST(req: Request) {
const { slug } = await req.json();
revalidatePath(`/blog/${slug}`);
return Response.json({ revalidated: true });
}