Analytics Architecture on KeystoneJS
KeystoneJS is a Node.js headless CMS that provides a GraphQL API and an auto-generated Admin UI. Content is defined via schema configuration in keystone.ts, and the frontend is typically a separate application (Next.js, React, etc.) that consumes data through GraphQL.
Analytics implementation spans two layers:
- Frontend application - Where tracking scripts, data layers, and event firing live
- KeystoneJS backend - Where server-side event tracking, document hooks, and custom mutations handle analytics data collection
The schema definition drives the content model:
// keystone.ts
import { config, list } from '@keystone-6/core';
import { text, timestamp, select, relationship } from '@keystone-6/core/fields';
export default config({
db: { provider: 'postgresql', url: process.env.DATABASE_URL },
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
slug: text({ isIndexed: 'unique' }),
status: select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
}),
author: relationship({ ref: 'User.posts', many: false }),
category: relationship({ ref: 'Category.posts', many: false }),
publishedAt: timestamp(),
},
}),
},
});
Installing Tracking Scripts
Next.js Frontend (App Router)
In a Next.js frontend consuming KeystoneJS data, add tracking scripts in the root layout:
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script id="gtm-head" 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>
);
}
React SPA Frontend
For a standalone React app, add GTM in index.html or via a component:
// components/Analytics.tsx
import { useEffect } from 'react';
export function Analytics({ gtmId }: { gtmId: string }) {
useEffect(() => {
if (typeof window !== 'undefined' && !window.dataLayer) {
window.dataLayer = [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtm.js?id=${gtmId}`;
document.head.appendChild(script);
}
}, [gtmId]);
return null;
}
Server-Side Tracking via Document Hooks
KeystoneJS document hooks fire on CRUD operations. Use afterOperation hooks to send server-side analytics events:
// keystone.ts
import { list } from '@keystone-6/core';
import { text, timestamp } from '@keystone-6/core/fields';
export const Post = list({
fields: {
title: text(),
slug: text({ isIndexed: 'unique' }),
publishedAt: timestamp(),
},
hooks: {
afterOperation: async ({ operation, item, context }) => {
if (operation === 'create') {
// Send to analytics endpoint
await fetch(process.env.ANALYTICS_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'content_created',
contentType: 'Post',
contentId: item.id,
title: item.title,
timestamp: new Date().toISOString(),
}),
}).catch(console.error);
}
if (operation === 'update' && item.status === 'published') {
await fetch(process.env.ANALYTICS_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'content_published',
contentType: 'Post',
contentId: item.id,
title: item.title,
timestamp: new Date().toISOString(),
}),
}).catch(console.error);
}
},
beforeOperation: async ({ operation, resolvedData, context }) => {
// Validate or transform data before write
if (operation === 'create' && resolvedData?.slug) {
// Log content creation attempt
console.log(`Creating post: ${resolvedData.slug}`);
}
},
},
});
Custom GraphQL Mutation for Event Tracking
Extend the GraphQL schema to accept analytics events from the frontend:
// keystone.ts
import { graphql } from '@keystone-6/core';
export default config({
// ... lists config
extendGraphqlSchema: graphql.extend(base => ({
mutation: {
trackEvent: graphql.field({
type: graphql.Boolean,
args: {
event: graphql.arg({ type: graphql.nonNull(graphql.String) }),
properties: graphql.arg({ type: graphql.JSON }),
},
resolve: async (root, { event, properties }, context) => {
// Store event or forward to analytics service
await context.db.AnalyticsEvent.createOne({
data: {
eventName: event,
properties: JSON.stringify(properties),
timestamp: new Date().toISOString(),
userId: context.session?.itemId || null,
},
});
return true;
},
}),
},
})),
});
Data Layer Implementation
In a Next.js frontend, build the data layer from GraphQL query results:
// app/posts/[slug]/page.tsx
import { gql } from 'graphql-request';
import { keystoneClient } from '@/lib/keystone';
import { DataLayer } from '@/components/DataLayer';
const GET_POST = gql`
query GetPost($slug: String!) {
post(where: { slug: $slug }) {
id
title
slug
status
publishedAt
author { name }
category { name slug }
}
}
`;
export default async function PostPage({ params }: { params: { slug: string } }) {
const { post } = await keystoneClient.request(GET_POST, { slug: params.slug });
return (
<>
<DataLayer data={{
contentType: 'post',
contentId: post.id,
pageTitle: post.title,
pageSlug: post.slug,
pageAuthor: post.author?.name,
pageCategory: post.category?.name,
publishedAt: post.publishedAt,
contentStatus: post.status,
}} />
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
);
}
The DataLayer component:
// components/DataLayer.tsx
'use client';
import { useEffect } from 'react';
export function DataLayer({ data }: { data: Record<string, unknown> }) {
useEffect(() => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(data);
}, [data]);
return null;
}
Common Issues
GraphQL response shapes vary by query. KeystoneJS returns null for relationships that are not populated. Always use optional chaining (post.author?.name) in data layer construction to prevent runtime errors.
Document hooks fire on Admin UI operations too. The afterOperation hook fires for both API and Admin UI changes. If you only want to track frontend-initiated events, check the context or add a flag to distinguish the source.
Next.js strategy="beforeInteractive" requires <head> placement. The beforeInteractive strategy in Next.js Script component only works when placed inside <head> in the root layout. Placing it elsewhere causes the script to load after hydration.
Session context unavailable in public queries. If your KeystoneJS instance uses session-based access control, anonymous GraphQL queries may not have context.session. Custom mutations for analytics tracking should handle the unauthenticated case.
Hot module replacement resets window.dataLayer. During Next.js development, HMR can cause the data layer component to re-mount and push duplicate events. Use a deduplication check or event ID in development mode.
Platform-Specific Considerations
KeystoneJS is a headless CMS, meaning it has no frontend rendering layer. All analytics implementation happens in whatever frontend framework consumes the GraphQL API. This gives full control over tracking architecture but requires building all data layer logic from scratch.
The GraphQL API supports filtering, pagination, and relationship traversal, which enables building complex data layer objects that combine data from multiple content types in a single query. Use GraphQL fragments to standardize the data shape across different page types.
KeystoneJS supports PostgreSQL, MySQL, and SQLite via Prisma. For server-side analytics that query content metadata directly, you can access the Prisma client through context.prisma in hooks and custom resolvers.
Access control in KeystoneJS is field-level. If certain content fields are restricted, they will return null in GraphQL responses even if requested. Ensure analytics data layer fields are accessible to the public access control policy, or the data layer will have missing values for anonymous users.