Analytics Implementation on TinaCMS | OpsBlu Docs

Analytics Implementation on TinaCMS

Tracking script setup, data layer patterns, and event handling for TinaCMS sites using Git-backed content, TinaCloud, and Next.js integration.

Analytics Architecture on TinaCMS

TinaCMS is a Git-backed headless CMS with visual editing capabilities. Content is stored as Markdown or JSON files in your Git repository and served through a GraphQL API provided by TinaCloud (hosted) or a local GraphQL server (self-hosted). The frontend is typically a Next.js or React application with the Tina Provider wrapping the component tree for visual editing.

Content delivery flow:

Git Repository (Markdown / JSON files)
    |
    v
TinaCloud GraphQL API (or local Tina server)
    |
    v
Next.js / React Frontend (Tina Provider for visual editing)
    |
    v
HTML Output (where tracking scripts execute)

Key components for analytics:

  • Git-backed content -- Content files live in the repository. Changes create Git commits. The Git history itself serves as an audit trail of content changes.
  • TinaCloud -- Hosted GraphQL API and authentication layer. Provides the data endpoint for both public reads and authenticated visual editing.
  • Tina Provider -- React context provider that wraps the application. Enables inline visual editing when authenticated. Must be distinguished from public visitors for analytics purposes.
  • GraphQL queries -- Tina generates a typed GraphQL schema from your content models. Query responses include metadata fields usable in the data layer.
  • Content modeling -- Defined in tina/config.ts. Schema definitions determine what fields are available for tracking.

Installing Tracking Scripts

TinaCMS sites are typically Next.js applications. Script installation follows Next.js patterns.

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

Next.js (Pages Router)

// pages/_app.tsx
import Script from 'next/script';
import { TinaEditProvider } from 'tinacms/dist/edit-state';

export default function App({ Component, pageProps }) {
  return (
    <>
      <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');`
        }}
      />
      <TinaEditProvider editMode={<Component {...pageProps} />}>
        <Component {...pageProps} />
      </TinaEditProvider>
    </>
  );
}

Suppressing Analytics in Edit Mode

When Tina is in edit mode (visual editing active), analytics should not fire. The Tina client exposes the editing state:

'use client';
import { useTina } from 'tinacms/dist/react';

export function AnalyticsLoader() {
  // Check if Tina edit mode is active via URL parameter
  const isEditing = typeof window !== 'undefined' &&
    window.location.search.includes('tina-edit');

  if (isEditing) return null;

  return (
    <script
      dangerouslySetInnerHTML={{
        __html: `/* GTM snippet here */`
      }}
    />
  );
}

Data Layer Implementation

Pushing Content Metadata from Tina GraphQL

Tina generates typed GraphQL queries from your content model. Query content fields and push to the data layer:

// app/blog/[slug]/page.tsx
import { client } from '@/tina/__generated__/client';

export default async function BlogPost({ params }) {
  const { data } = await client.queries.post({ relativePath: `${params.slug}.mdx` });
  const post = data.post;

  return (
    <>
      <script
        dangerouslySetInnerHTML={{
          __html: `window.dataLayer = window.dataLayer || [];
          window.dataLayer.push({
            event: 'page_data_ready',
            content_type: 'post',
            content_id: '${post._sys.filename}',
            content_title: '${post.title?.replace(/'/g, "\\'") || ""}',
            content_author: '${post.author || "unknown"}',
            content_category: '${post.category || "uncategorized"}',
            content_date: '${post.date || ""}'
          });`
        }}
      />
      {/* render post */}
    </>
  );
}

Tracking Inline Rich Text Interactions

Tina's rich text field renders custom components. Add event tracking to embedded interactive elements:

// components/mdx-components.tsx
const components = {
  Cta: ({ label, url, id }) => (
    <a
      href={url} => {
        window.dataLayer?.push({
          event: 'cta_click',
          cta_id: id,
          cta_text: label,
          cta_location: 'content_body'
        });
      }}
    >
      {label}
    </a>
  ),
  Video: ({ src, title }) => (
    <video
      src={src} => {
        window.dataLayer?.push({
          event: 'video_play',
          video_title: title,
          video_src: src
        });
      }}
    />
  )
};

Using Content Model Fields for Tracking

Define tracking-specific fields in your Tina schema:

// tina/config.ts
import { defineConfig } from 'tinacms';

export default defineConfig({
  schema: {
    collections: [
      {
        name: 'post',
        label: 'Blog Posts',
        path: 'content/posts',
        fields: [
          { name: 'title', label: 'Title', type: 'string', required: true },
          { name: 'author', label: 'Author', type: 'string' },
          { name: 'category', label: 'Category', type: 'string' },
          { name: 'date', label: 'Publish Date', type: 'datetime' },
          // Analytics-specific fields
          { name: 'campaign_id', label: 'Campaign ID', type: 'string' },
          { name: 'tracking_group', label: 'Tracking Group', type: 'string',
            options: ['organic', 'paid', 'email', 'social'] },
          { name: 'body', label: 'Body', type: 'rich-text', isBody: true }
        ]
      }
    ]
  }
});

Server-Side Events via Git Webhooks

Since TinaCMS content is Git-backed, use Git platform webhooks (GitHub, GitLab) to track content changes:

GitHub Webhook Settings:
  URL: https://yoursite.com/api/content-webhook
  Events: push
  Filter: content/** (if supported by your webhook handler)

Webhook handler:

// app/api/content-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('x-hub-signature-256');

  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
    .update(body)
    .digest('hex');

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

  const payload = JSON.parse(body);
  const contentCommits = payload.commits?.filter(
    (c) => c.added.some(f => f.startsWith('content/')) ||
           c.modified.some(f => f.startsWith('content/'))
  );

  if (contentCommits?.length > 0) {
    await fetch(
      `https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=SECRET`,
      {
        method: 'POST',
        body: JSON.stringify({
          client_id: 'tinacms-git',
          events: [{
            name: 'content_published',
            params: {
              files_changed: contentCommits.flatMap(c => [...c.added, ...c.modified]).length,
              commit_message: contentCommits[0].message
            }
          }]
        })
      }
    );
  }

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

Common Issues

Visual editing mode firing analytics events

When a content editor is using Tina's visual editing, they see the live site with editable fields. GTM and other tracking scripts fire during these sessions, polluting production analytics with editor activity.

Detection approaches:

// Check for Tina edit mode URL parameters
const isTinaEdit = typeof window !== 'undefined' &&
  (window.location.pathname.includes('/admin') ||
   window.location.search.includes('tina-edit'));

// Check for TinaCloud authentication
const isTinaAuthenticated = document.cookie.includes('tina_token');

Suppress analytics based on these checks.

Static generation and stale data layers

TinaCMS sites often use Next.js Static Site Generation (SSG). Content metadata baked into the data layer at build time becomes stale when content is updated but the site has not been rebuilt.

Trigger rebuilds on content change using TinaCloud's build hooks or GitHub webhooks to your hosting platform:

TinaCloud > Project Settings > Build Hooks
  URL: https://api.vercel.com/v1/integrations/deploy/prj_xxxxx/yyyyyyy

_sys fields not included in query

Tina's generated GraphQL includes _sys fields (filename, path, extension, etc.) on every document. These are useful for the data layer but must be explicitly queried:

query {
  post(relativePath: "my-post.mdx") {
    _sys {
      filename
      path
      extension
    }
    title
    author
  }
}

Branch-based content causing analytics divergence

TinaCloud supports branch-based content editing. If editors work on a non-main branch, preview deployments of that branch will have different content than production. Ensure analytics tracking IDs differ between preview and production deployments:

const GA_ID = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
  ? 'G-PROD'
  : 'G-PREVIEW';

Platform-Specific Considerations

Git commit history as analytics -- Every content change in TinaCMS creates a Git commit. You can parse the Git log for content change frequency, authorship patterns, and editorial velocity without any external analytics platform.

TinaCloud vs. self-hosted -- TinaCloud provides hosted authentication and GraphQL. Self-hosted Tina uses a local GraphQL server. Both serve the same content, but TinaCloud adds authentication that distinguishes editors from public visitors. Self-hosted setups need a separate mechanism to identify edit sessions.

MDX component tracking -- TinaCMS supports MDX, allowing React components in Markdown content. Each custom component is an opportunity for event tracking, but components must be registered both in the Tina schema (for editing) and in the MDX renderer (for display). Mismatches cause components to render as raw text, losing tracking capabilities.

Content file format -- Tina supports Markdown, MDX, and JSON content files. Markdown frontmatter fields are available in GraphQL queries. If you store tracking metadata in frontmatter, it is queryable and can be included in the data layer.

Local development -- tinacms dev starts a local GraphQL server alongside the Next.js dev server. Analytics scripts will fire during local development unless you gate them behind an environment check:

const isProduction = process.env.NODE_ENV === 'production';