Analytics Architecture with Strapi
Strapi is an open-source headless CMS built on Node.js. Like all headless CMS platforms, Strapi has no frontend -- it serves content via REST and GraphQL APIs. All client-side analytics implementation happens in the frontend application that consumes Strapi's APIs.
What makes Strapi distinct for analytics is its server-side extensibility. Strapi supports lifecycle hooks, custom controllers, custom routes, and middleware that can trigger server-side analytics events. This gives you two tracking layers:
Client-side tracking happens in your frontend framework (Next.js, Nuxt, Gatsby, SvelteKit, Astro). You install GTM or GA4 in the framework's layout, push Strapi content metadata to the data layer, and handle SPA route change tracking.
Server-side tracking uses Strapi's lifecycle hooks and custom middleware to send events via Measurement Protocol APIs. This captures events clients cannot see -- content publication, API consumption patterns, and editorial workflow actions.
Strapi v4 and v5 have different API response formats. v4 wraps data in { data: { id, attributes: {...} } } while v5 flattens to { data: { id, ...fields } }. Your data layer code must account for the version you are running.
Installing Tracking Scripts
Since Strapi has no frontend, scripts are installed in your frontend framework. The example below uses Next.js App Router:
// app/layout.tsx (Next.js App Router)
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script
id="gtm-script"
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>
<noscript>
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style={{ display: 'none', visibility: 'hidden' }} />
</noscript>
{children}
</body>
</html>
);
}
For Nuxt 3, add the GTM snippet in nuxt.config.ts under app.head.script. For SvelteKit, place the snippet directly in src/app.html inside the <head>. For Gatsby, use gatsby-ssr.js with setHeadComponents.
Data Layer Setup
Strapi content types provide structured metadata -- IDs, content type names, timestamps, locales, relations, and custom fields. Push this data to the data layer for content-level analytics segmentation.
Strapi v4 Data Layer Helper
// lib/analytics.ts (Strapi v4 response format)
export function pushStrapiV4Entry(
response: { data: { id: number; attributes: Record<string, any> } },
contentType: string,
extraFields?: Record<string, unknown>
) {
if (typeof window === 'undefined') return;
const entry = response.data;
const attrs = entry.attributes;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'strapi_content_view',
content_id: entry.id,
content_type: contentType,
content_title: attrs.title || attrs.name || '',
content_slug: attrs.slug || '',
content_locale: attrs.locale || 'en',
content_created_at: attrs.createdAt,
content_updated_at: attrs.updatedAt,
content_published_at: attrs.publishedAt,
...extraFields,
});
}
Strapi v5 Data Layer Helper
// lib/analytics.ts (Strapi v5 flattened response format)
export function pushStrapiV5Entry(
entry: { id: number; documentId: string; [key: string]: any },
contentType: string,
extraFields?: Record<string, unknown>
) {
if (typeof window === 'undefined') return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'strapi_content_view',
content_id: entry.id,
content_document_id: entry.documentId,
content_type: contentType,
content_title: entry.title || entry.name || '',
content_slug: entry.slug || '',
content_locale: entry.locale || 'en',
content_published_at: entry.publishedAt,
...extraFields,
});
}
Blog Post with Relations (v4 Example)
// app/blog/[slug]/page.tsx (Next.js + Strapi v4)
import { StrapiTracker } from '@/components/StrapiTracker';
async function getPost(slug: string) {
const res = await fetch(
`${process.env.STRAPI_URL}/api/articles?filters[slug][$eq]=${slug}&populate=category,author,tags`
);
return res.json();
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const response = await getPost(params.slug);
const post = response.data[0];
return (
<article>
<StrapiTracker entry={post} contentType="article" extraFields={{
post_category: post.attributes.category?.data?.attributes?.name || 'uncategorized',
post_author: post.attributes.author?.data?.attributes?.name || 'unknown',
post_tags: post.attributes.tags?.data?.map((t: any) => t.attributes.name) || [],
}} />
<h1>{post.attributes.title}</h1>
</article>
);
}
// components/StrapiTracker.tsx
'use client';
import { useEffect } from 'react';
export function StrapiTracker({ entry, contentType, extraFields }: {
entry: { id: number; attributes: Record<string, any> };
contentType: string;
extraFields?: Record<string, unknown>;
}) {
useEffect(() => {
const attrs = entry.attributes;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'strapi_content_view',
content_id: entry.id,
content_type: contentType,
content_title: attrs.title || attrs.name || '',
content_slug: attrs.slug || '',
content_published_at: attrs.publishedAt,
...extraFields,
});
}, [entry.id]);
return null;
}
SPA Route Change Tracking
All Strapi frontends with client-side routing need virtual page view tracking:
// components/RouteChangeTracker.tsx (Next.js App Router)
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function RouteChangeTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
const isFirstLoad = useRef(true);
useEffect(() => {
if (isFirstLoad.current) { isFirstLoad.current = false; return; }
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_page_view',
page_path: pathname + (searchParams.toString() ? '?' + searchParams.toString() : ''),
page_title: document.title,
});
}, [pathname, searchParams]);
return null;
}
For Nuxt 3, use router.afterEach() in a client plugin. For SvelteKit, use afterNavigate from $app/navigation.
Server-Side Tracking with Lifecycle Hooks
Strapi's lifecycle hooks let you send server-side analytics events when content is created, published, or deleted. This captures editorial workflow events invisible to client-side tracking.
// src/api/article/content-types/article/lifecycles.js (Strapi v4)
module.exports = {
async afterCreate(event) {
sendServerEvent('content_created', event.result);
},
async afterUpdate(event) {
if (event.result.publishedAt && !event.params.data._previousPublishedAt) {
sendServerEvent('content_published', event.result);
}
},
};
function sendServerEvent(eventName, entry) {
const measurementId = process.env.GA4_MEASUREMENT_ID;
const apiSecret = process.env.GA4_API_SECRET;
if (!measurementId || !apiSecret) return;
fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${apiSecret}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: 'strapi-server',
events: [{
name: eventName,
params: {
content_id: String(entry.id),
content_type: 'article',
content_title: entry.title || '',
engagement_time_msec: '1',
},
}],
}),
}
).catch((err) => strapi.log.warn(`Analytics event failed: ${err.message}`));
}
API Request Tracking Middleware
Track how your Strapi API is consumed by creating a custom middleware:
// src/middlewares/api-analytics.js (Strapi v4)
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
await next();
if (ctx.request.url.startsWith('/api/')) {
strapi.log.info(`API_ANALYTICS: ${JSON.stringify({
timestamp: new Date().toISOString(),
method: ctx.request.method,
path: ctx.request.url,
status: ctx.response.status,
duration_ms: Date.now() - start,
content_type: ctx.request.url.split('/api/')[1]?.split('/')[0] || 'unknown',
})}`);
}
};
};
Register it in config/middlewares.js by adding { resolve: './src/middlewares/api-analytics' } to the array.
Common Errors
| Error | Cause | Fix |
|---|---|---|
Data layer push fires with undefined values |
Frontend uses Strapi v5 access pattern (entry.title) on a v4 response wrapped in attributes |
Check your Strapi version: use entry.attributes.title for v4, entry.title for v5 |
populate parameter not returning related data |
Strapi v4 does not populate relations by default | Add ?populate=* for all relations or ?populate=category,author for specific ones |
| Server-side lifecycle events do not appear in GA4 | GA4 Measurement Protocol requires engagement_time_msec |
Include engagement_time_msec: '1' in event params |
| Duplicate virtual page views on initial load | Route change listener fires both on SSR hydration and first client mount | Use a ref flag (isFirstLoad) to skip the first useEffect execution |
| Draft content appears in analytics data | Frontend fetches include unpublished entries | Add &publicationState=live (v4) or &status=published (v5) to API queries |
| Locale-specific content pushes wrong locale | Multi-locale Strapi returns a locale field but frontend reads from wrong level |
Extract locale from the entry's locale field directly, not from URL path parsing |
GraphQL returns null for nested relations |
GraphQL requires explicit field selection; populate does not apply |
Define nested queries explicitly: category { data { attributes { name } } } |
| Lifecycle hooks fire multiple times per save | Strapi triggers afterUpdate for each locale on multi-locale content |
Check event.result.locale and only send analytics for the default locale |
| API middleware slows responses | Middleware awaits external HTTP call on every request | Fire analytics calls without await (fire-and-forget) or batch and flush periodically |
| Custom controller bypasses lifecycle hooks | Replacing a default controller entirely skips entity service hooks | Use strapi.entityService.create() / .update() instead of direct DB queries |
Performance Considerations
Use
next/scriptwithstrategy="afterInteractive"in Next.js. This ensures GTM loads after page hydration. For Nuxt, useuseHeadwithdefer: true. Avoid synchronous script loading that blocks the main thread.Minimize Strapi API response payload. Use
fieldsandpopulateparameters to request only what your data layer needs:?fields[0]=title&fields[1]=slug&populate[category][fields][0]=name.Use Static Site Generation where possible. SSG embeds Strapi data directly in HTML at build time, making data layer values available immediately without client-side API calls.
Server-side lifecycle events should be fire-and-forget. Do not
awaitthe HTTP call to GA4 Measurement Protocol in lifecycle hooks. If the analytics service is slow, it should not block content operations.Cache Strapi API responses in your frontend. Next.js ISR, Nuxt caching, and Gatsby build cache reduce Strapi API calls, lowering SSR latency and improving time-to-interactive.
Configure a CDN for Strapi media assets. If using local uploads, assets are served from the Strapi server. Configure Cloudinary or S3 + CloudFront for media to avoid LCP regressions from slow image loading.