Analytics Implementation on Hygraph (GraphCMS) | OpsBlu Docs

Analytics Implementation on Hygraph (GraphCMS)

Tracking setup, data layer patterns, and event handling for Hygraph sites using the GraphQL API, Content Federation, and frontend frameworks.

Analytics Architecture on Hygraph

Hygraph (formerly GraphCMS) is a GraphQL-native headless CMS. It exposes content exclusively through GraphQL APIs -- a Content API for reads and a Management API for writes. There is no rendering layer. All analytics implementation happens in the frontend framework that queries the API.

Content delivery flow:

Hygraph GraphQL Content API
    |
    v
Frontend Framework (Next.js / Nuxt / Gatsby / Remix)
    |
    v
HTML Output (where tracking scripts execute)

Key architectural components for analytics:

  • GraphQL Content API -- Read-only, CDN-backed endpoint. Queries return typed content with system fields (createdAt, updatedAt, publishedAt, stage) usable in the data layer.
  • Content Federation -- Hygraph can pull data from remote GraphQL sources and REST APIs. Federated content appears alongside native content in queries. Analytics metadata from remote sources can be included in the same query.
  • Webhooks -- Triggered on content lifecycle events (create, update, publish, unpublish, delete). Configurable per model and stage.
  • Remote Sources -- External data sources integrated into the Hygraph schema. Can pull analytics data from external services into the CMS for editorial context.
  • Mutations -- The Management API supports GraphQL mutations. Custom events can be written back to Hygraph as structured content (e.g., logging analytics summaries as content entries).

Installing Tracking Scripts

Hygraph does not serve HTML or inject scripts. Your frontend handles all script placement.

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

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');`
      }}
    />
  ]);
}

Remix

// app/root.tsx
export default function App() {
  return (
    <html>
      <head>
        <script
          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');`
          }}
        />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}

Data Layer Implementation

Pushing Content Metadata from GraphQL Queries

Query content with system metadata fields and push to the data layer:

// app/blog/[slug]/page.tsx
const QUERY = `
  query Post($slug: String!) {
    post(where: { slug: $slug }, stage: PUBLISHED) {
      id
      title
      slug
      createdAt
      publishedAt
      updatedAt
      author {
        name
      }
      category {
        name
      }
    }
  }
`;

export default async function BlogPost({ params }) {
  const { post } = await hygraphClient.request(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?.name || "unknown"}',
            content_category: '${post.category?.name || "uncategorized"}',
            content_published: '${post.publishedAt}',
            content_updated: '${post.updatedAt}'
          });`
        }}
      />
      {/* render post */}
    </>
  );
}

Rich Text Block Tracking

Hygraph Rich Text fields can contain embedded assets and custom embeds. Track interactions in your renderer:

import { RichText } from '@graphcms/rich-text-react-renderer';

const renderers = {
  embed: {
    Asset: ({ url, mimeType, id }) => {
      if (mimeType?.startsWith('video/')) {
        return (
          <video
            src={url} => {
              window.dataLayer?.push({
                event: 'video_play',
                video_id: id,
                video_url: url
              });
            }}
          />
        );
      }
      return <img src={url} />;
    }
  }
};

export function Content({ richText }) {
  return <RichText content={richText.json} references={richText.references} renderers={renderers} />;
}

Server-Side Events via Webhooks

Configure webhooks in Hygraph Settings > Webhooks:

URL: https://yoursite.com/api/hygraph-webhook
Models: Post, Page
Stages: PUBLISHED
Triggers: Publish, Unpublish, Delete

Webhook handler:

// app/api/hygraph-webhook/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('gcms-signature');

  // Verify webhook signature
  const expectedSig = crypto
    .createHmac('sha256', process.env.HYGRAPH_WEBHOOK_SECRET)
    .update(body)
    .digest('base64');

  if (signature !== expectedSig) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
  }

  const payload = JSON.parse(body);
  const { operation, data } = payload;

  await fetch(
    `https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=SECRET`,
    {
      method: 'POST',
      body: JSON.stringify({
        client_id: 'hygraph-cms',
        events: [{
          name: 'content_event',
          params: {
            action: operation,
            content_type: data.__typename,
            content_id: data.id,
            content_stage: data.stage
          }
        }]
      })
    }
  );

  return NextResponse.json({ ok: true });
}

Writing Analytics Data Back via Mutations

Use the Management API to store analytics summaries as content entries:

const MUTATION = `
  mutation CreateAnalyticsEntry($postId: ID!, $views: Int!, $date: Date!) {
    createAnalyticsEntry(data: {
      post: { connect: { id: $postId } }
      pageViews: $views
      date: $date
    }) {
      id
    }
  }
`;

await hygraphManagementClient.request(MUTATION, {
  postId: 'clxxxxxxxxx',
  views: 1523,
  date: '2026-03-01'
});

Common Issues

Stage mismatch -- querying DRAFT content in production

Hygraph uses content stages (DRAFT and PUBLISHED). If your frontend queries stage: DRAFT or omits the stage parameter, it may serve unpublished content. Analytics on draft content inflates metrics.

# Always specify stage for production queries
query {
  posts(stage: PUBLISHED) {
    id
    title
  }
}

Client-side navigation not tracked

Hygraph frontends built with Next.js or Gatsby use client-side routing. Standard GTM page view triggers only fire on full page loads.

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

Webhook not firing for scheduled publishes

Hygraph supports scheduled publishing. Webhooks fire when the scheduled publish executes, not when it is scheduled. If you track "content published" events, the timestamp in your analytics will reflect the actual publish time, not the scheduling time.

Localized content missing from data layer

Hygraph supports field-level localization. The GraphQL API requires a locales argument to return localized content. If you omit it, you get the default locale only:

query {
  post(where: { slug: "example" }, locales: [en, de]) {
    title
    localizations {
      locale
      title
    }
  }
}

Include the active locale in your data layer push for accurate locale-based analytics segmentation.


Platform-Specific Considerations

Content Federation -- Hygraph can federate content from external GraphQL and REST APIs. If you are pulling product data from a Shopify remote source, the federated fields are available in the same query as native Hygraph content. Include federated metadata in your data layer for unified analytics.

API endpoint regions -- Hygraph provides region-specific API endpoints (EU, US). The endpoint URL determines CDN routing. Analytics latency measurements should account for the API region relative to your users.

Asset handling -- Hygraph assets are served from media.graphassets.com. Like other CDN-served assets, URL parameters vary by requested dimensions. This affects unique URL counts in network analytics.

Rate limits -- The Content API has rate limits that vary by plan. Shared/free tiers allow 5 requests/second. If you build analytics dashboards that query Hygraph directly, implement caching to avoid throttling.

Environments -- Hygraph supports multiple environments (similar to branches). Each environment has its own API endpoint. Ensure production analytics scripts only load when the frontend connects to the production environment endpoint.