Analytics Architecture on Kontent.ai
Kontent.ai (formerly Kentico Kontent) is a headless CMS that delivers content exclusively through APIs. There is no server-rendered frontend; all page rendering happens in a separate application (Next.js, React, Gatsby, etc.) that fetches content via the Delivery API or Management API.
The content delivery flow:
Kontent.ai CMS → Delivery API (REST) → Frontend App → Browser (analytics scripts)
Management API (REST)
GraphQL Endpoint (optional)
SDKs available for frontend integration:
- JavaScript/TypeScript SDK:
@kontent-ai/delivery-sdk - React SDK:
@kontent-ai/gatsby-source(Gatsby), direct SDK use in Next.js - .NET SDK:
Kontent.Ai.Delivery
The Delivery Client is the primary interface:
import { createDeliveryClient } from '@kontent-ai/delivery-sdk';
const deliveryClient = createDeliveryClient({
environmentId: process.env.KONTENT_ENVIRONMENT_ID!,
// Optional: preview API key for draft content
previewApiKey: process.env.KONTENT_PREVIEW_KEY,
});
Since Kontent.ai has no frontend layer, all analytics implementation lives entirely in the consuming application.
Installing Tracking Scripts
Next.js Frontend (App Router)
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script id="gtm" strategy="beforeInteractive">
{`(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','${process.env.NEXT_PUBLIC_GTM_ID}');`}
</Script>
</head>
<body>
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${process.env.NEXT_PUBLIC_GTM_ID}`}
height="0" width="0" style={{ display: 'none', visibility: 'hidden' }}
/>
</noscript>
{children}
</body>
</html>
);
}
Gatsby Frontend
In Gatsby, use gatsby-ssr.js for script injection:
// gatsby-ssr.js
export const setHeadComponents, setPreBodyComponents }) => {
const gtmId = process.env.GATSBY_GTM_ID;
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','${gtmId}');`
}} />,
]);
setPreBodyComponents([
<noscript key="gtm-ns">
<iframe src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
height="0" width="0" style={{ display: 'none', visibility: 'hidden' }} />
</noscript>,
]);
};
Webhook-Based Server-Side Tracking
Kontent.ai fires webhooks on content lifecycle events. Set up a webhook endpoint to capture CMS events for server-side analytics:
// api/kontent-webhook.ts (Next.js API route)
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('x-kc-signature');
// Verify webhook signature
const hash = crypto
.createHmac('sha256', process.env.KONTENT_WEBHOOK_SECRET!)
.update(body)
.digest('base64');
if (hash !== signature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(body);
// Track content events
for (const item of payload.data.items) {
await fetch(process.env.ANALYTICS_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: `content_${payload.message.type}`,
contentId: item.id,
codename: item.codename,
language: item.language,
type: item.type,
timestamp: payload.message.created_timestamp,
}),
});
}
return NextResponse.json({ ok: true });
}
Data Layer Implementation
Fetch content from the Delivery API and build the data layer from the response:
// lib/kontent.ts
import { createDeliveryClient } from '@kontent-ai/delivery-sdk';
const client = createDeliveryClient({
environmentId: process.env.KONTENT_ENVIRONMENT_ID!,
});
export async function getArticle(slug: string) {
const response = await client
.items()
.type('article')
.equalsFilter('elements.slug', slug)
.toPromise();
return response.data.items[0];
}
// app/articles/[slug]/page.tsx
import { getArticle } from '@/lib/kontent';
import { DataLayer } from '@/components/DataLayer';
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug);
const dataLayerValues = {
contentType: article.system.type,
contentId: article.system.id,
codename: article.system.codename,
pageTitle: article.elements.title.value,
pageSlug: article.elements.slug.value,
language: article.system.language,
lastModified: article.system.lastModified,
collection: article.system.collection,
workflow: article.system.workflowStep,
};
// Add taxonomy elements if present
if (article.elements.category) {
dataLayerValues.category = article.elements.category.value.map(
(term: { codename: string; name: string }) => term.name
);
}
return (
<>
<DataLayer data={dataLayerValues} />
<article>
<h1>{article.elements.title.value}</h1>
{/* Render content */}
</article>
</>
);
}
For listing pages with multiple items:
// app/articles/page.tsx
export default async function ArticlesPage() {
const response = await client
.items()
.type('article')
.orderByDescending('system.last_modified')
.limitParameter(20)
.toPromise();
return (
<>
<DataLayer data={{
contentType: 'listing',
listingType: 'article',
itemCount: response.data.items.length,
pagination: response.data.pagination,
}} />
{/* Render list */}
</>
);
}
Common Issues
Content item structure differs from content type schema. Kontent.ai returns elements nested under item.elements.[codename].value. The element codename is not always the display name. Use the content type's codename (defined in Kontent.ai) rather than the display name when accessing elements.
Rich text elements contain linked items. Rich text fields can reference other content items inline. When building a data layer from rich text content, the linked items are in item.elements.[field].linkedItems, not in the value string. Parsing the rich text HTML for analytics (word count, link count) requires resolving these references.
Preview vs. published content. The Delivery API returns only published content by default. If using the Preview API (with previewApiKey), draft content will appear. Ensure analytics events distinguish between preview and production traffic by checking the API mode.
Webhook delivery is not guaranteed. Kontent.ai webhooks use at-least-once delivery. Your webhook handler should be idempotent. Deduplicate events using the message.id field from the webhook payload.
SDK caching masks content updates. The Delivery SDK caches responses by default. If analytics needs real-time content metadata, configure the cache TTL or use the waitForLoadingNewContent option when content has been recently updated.
Platform-Specific Considerations
Kontent.ai is fully headless. There are no templates, themes, or server-side rendering in the CMS itself. Every analytics decision is a frontend architecture decision. The CMS only provides structured content via API.
Custom elements in Kontent.ai can embed external UIs inside the content editor. These custom elements can include their own tracking scripts for editor behavior analytics, but this is separate from frontend visitor analytics.
Kontent.ai's content model uses codenames (machine names) and display names. When building analytics taxonomies, use codenames for consistent programmatic values and display names only for human-readable reports.
The Management API allows reading content workflow states (Draft, Review, Published, Archived). You can build content lifecycle analytics by periodically querying the Management API for workflow step distributions, but this requires a Management API key with read permissions, which should never be exposed to the frontend.
For multi-language sites, each content item has variants per language. The system.language field in the Delivery response indicates which variant was returned. Include this in the data layer to segment analytics by language.