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