Payload CMS Analytics: Collection Hooks, Custom Endpoints, | OpsBlu Docs

Payload CMS Analytics: Collection Hooks, Custom Endpoints,

Implement analytics on Payload CMS sites. Covers collection hooks for server-side tracking, custom API endpoints, Next.js frontend data layers, and...

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