Analytics Architecture on Payload CMS
Payload CMS is a headless, TypeScript-first CMS. Since version 2.0, Payload bundles with Next.js, and version 3.0 runs natively inside the Next.js App Router. This means the CMS admin panel and your frontend share the same application.
Analytics tracking lives in the frontend layer (Next.js pages and components), not in the Payload admin panel. Collection hooks (afterChange, afterRead, beforeChange) run server-side and enable tracking events that happen outside the browser, such as form submissions, content publishes, or API-driven actions. The admin panel runs on routes like /admin, so you need to filter admin traffic out of your analytics to avoid inflating pageview counts.
The typical setup: GTM or GA4 scripts load in a Next.js layout that wraps only frontend routes. Data layer values come from Payload's REST or Local API, fetched server-side in React Server Components and passed to client components for pushing to window.dataLayer.
Installing Scripts in the Next.js Frontend
Payload 3.0 uses the Next.js App Router. Place analytics scripts in the frontend layout, not the root layout (which also serves the admin panel).
Frontend layout with GTM:
// app/(frontend)/layout.tsx
import Script from 'next/script';
export default function FrontendLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Script id="gtm" strategy="afterInteractive">
{`(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>
{children}
</>
);
}
The (frontend) route group isolates this layout from the admin panel. Routes under app/(frontend)/ get the GTM script; routes under app/(payload)/admin/ do not.
Set the environment variable in .env:
NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX
The NEXT_PUBLIC_ prefix exposes the variable to client-side code. Never prefix server-only secrets (like GA API secrets) with NEXT_PUBLIC_.
Data Layer from Payload Collections
Fetch collection data in server components, then pass it to a client component that pushes to the data layer.
Server component fetching page data:
// app/(frontend)/[slug]/page.tsx
import { getPayloadClient } from '../../payload-client';
import { ContentAnalytics } from '../../components/ContentAnalytics';
export default async function Page({
params,
}: {
params: { slug: string };
}) {
const payload = await getPayloadClient();
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: params.slug } },
limit: 1,
});
const page = result.docs[0];
if (!page) return null;
return (
<>
<ContentAnalytics
contentType="page"
contentId={page.id}
contentTitle={page.title}
category={page.category?.title}
/>
<main>{/* render page content */}</main>
</>
);
}
Client component pushing to data layer:
// components/ContentAnalytics.tsx
'use client';
import { useEffect } from 'react';
interface ContentAnalyticsProps {
contentType: string;
contentId: string;
contentTitle: string;
category?: string;
}
export function ContentAnalytics({
contentType,
contentId,
contentTitle,
category,
}: ContentAnalyticsProps) {
useEffect(() => {
window.dataLayer?.push({
event: 'content_view',
content_type: contentType,
content_id: contentId,
content_title: contentTitle,
content_category: category || 'uncategorized',
});
}, [contentType, contentId, contentTitle, category]);
return null;
}
This pattern keeps Payload API calls on the server (no client-side fetch, no exposed API keys) while pushing structured data to GTM on the client.
Add the dataLayer type declaration to avoid TypeScript errors:
// types/global.d.ts
declare global {
interface Window {
dataLayer?: Record<string, unknown>[];
}
}
export {};
Server-Side Tracking with Collection Hooks
Collection hooks run on the Payload server. Use them to send events to GA4's Measurement Protocol for actions that happen without a browser, such as form submissions via API or content status changes.
Form submission hook with Measurement Protocol:
// collections/FormSubmissions.ts
import type { CollectionConfig } from 'payload/types';
const FormSubmissions: CollectionConfig = {
slug: 'form-submissions',
hooks: {
afterChange: [
async ({ doc, operation }) => {
if (operation === 'create') {
const measurementId = process.env.GA_MEASUREMENT_ID;
const apiSecret = process.env.GA_API_SECRET;
if (!measurementId || !apiSecret) return;
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${apiSecret}`,
{
method: 'POST',
body: JSON.stringify({
client_id: doc.clientId || 'server',
events: [
{
name: 'form_submit',
params: {
form_name: doc.formName,
submission_id: doc.id,
},
},
],
}),
}
);
}
},
],
},
fields: [
{ name: 'formName', type: 'text', required: true },
{ name: 'clientId', type: 'text' },
{ name: 'data', type: 'json' },
],
};
export default FormSubmissions;
The clientId field bridges server-side events to browser sessions. Pass the GA client ID from the frontend when submitting forms so the Measurement Protocol event joins the user's existing session.
Content publish tracking hook:
// collections/Posts.ts (partial, hooks only)
hooks: {
afterChange: [
async ({ doc, previousDoc, operation }) => {
if (
operation === 'update' &&
doc._status === 'published' &&
previousDoc._status === 'draft'
) {
// Track publish event server-side
console.log(`[analytics] Post published: ${doc.id} - ${doc.title}`);
// Send to Measurement Protocol, webhook, or logging service
}
},
],
},
Filtering Admin Panel Traffic
Because Payload and your frontend share a domain, analytics tools will capture admin panel pageviews unless you filter them. There are two approaches.
Approach 1: Route group isolation (recommended). Place GTM scripts only in app/(frontend)/layout.tsx. The admin panel under app/(payload)/ never loads analytics scripts.
Approach 2: GTM trigger filtering. If you cannot isolate layouts, add a trigger exception in GTM:
| Trigger Type | Condition |
|---|---|
| Page View - Exception | Page Path starts with /admin |
In GA4, create a data filter:
Admin > Data Streams > [Your Stream] > Configure Tag Settings >
Define Internal Traffic > Rule: IP or Page Path contains "/admin"
Approach 1 is cleaner because it prevents the scripts from loading at all, reducing admin panel load time and eliminating the need for downstream filtering.
Common Errors
| Symptom | Cause | Fix |
|---|---|---|
| GTM loads on admin panel | Script placed in root layout.tsx instead of (frontend)/layout.tsx |
Move the GTM Script component into the frontend route group layout |
| Data layer push has no effect | window.dataLayer is undefined at push time |
Initialize with `window.dataLayer = window.dataLayer |
NEXT_PUBLIC_GTM_ID is undefined |
Environment variable missing from .env or not prefixed |
Add NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX to .env; restart the dev server after changes |
| Server-side hook events not appearing in GA4 | Invalid or missing client_id in Measurement Protocol request |
The client_id parameter is required; use a server-generated UUID if no browser client ID is available |
TypeScript error on window.dataLayer |
Missing type declaration for dataLayer on Window |
Add a global.d.ts file declaring dataLayer on the Window interface |
Duplicate content_view events on navigation |
useEffect dependency array triggers on every render |
Include the contentId in the dependency array so the effect only fires when the content changes |
| Collection hook fires but fetch fails silently | No error handling on the Measurement Protocol fetch call | Wrap the fetch in a try/catch; log errors; do not let hook failures block the CMS operation |
| Analytics data missing after Payload upgrade | Collection field names changed between Payload versions | Check collection schema after upgrades; update hook references to match new field names |
Related Guides
- Google Analytics Setup -- GA4 config tag and Next.js Script component placement
- GTM Setup -- Container installation with route group isolation
- Data Layer Implementation -- Collection-powered data layer variables
- Troubleshooting -- Performance and tracking issue resolution