Analytics Implementation on Sanity CMS | OpsBlu Docs

Analytics Implementation on Sanity CMS

Tracking script installation, data layer patterns, and event firing for Sanity headless CMS with Next.js, Gatsby, and Nuxt frontends.

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 });
}