PayloadCMS Event Tracking with Google Analytics 4 | OpsBlu Docs

PayloadCMS Event Tracking with Google Analytics 4

Implement custom event tracking for PayloadCMS frontends including form submissions, content interactions, and user engagement.

Track user interactions on your PayloadCMS frontend including form submissions, content downloads, video plays, and custom events.

Prerequisites


Event Tracking Utility

Create a reusable event tracking utility:

File: lib/gtag.js

export const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_ID;

// Track custom events
export const event = ({ action, category, label, value }) => {
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', action, {
      event_category: category,
      event_label: label,
      value: value,
    });
  }
};

// Track GA4 recommended events
export const trackEvent = (eventName, eventParams = {}) => {
  if (typeof window !== 'undefined' && window.gtag) {
    window.gtag('event', eventName, eventParams);
  }
};

Form Tracking

Contact Form Submission

import { trackEvent } from '@/lib/gtag';

export default function ContactForm() {
  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);

    try {
      // Submit to Payload API
      const response = await fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/contact-submissions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: formData.get('name'),
          email: formData.get('email'),
          message: formData.get('message'),
        }),
      });

      if (response.ok) {
        // Track successful submission
        trackEvent('form_submission', {
          form_name: 'contact',
          form_id: 'contact_form',
          submission_status: 'success',
        });

        alert('Form submitted successfully!');
      }
    } catch (error) {
      // Track failed submission
      trackEvent('form_submission', {
        form_name: 'contact',
        form_id: 'contact_form',
        submission_status: 'error',
        error_message: error.message,
      });
    }
  };

  return (
    <form
      <input type="text" name="name" required />
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

Newsletter Signup

import { trackEvent } from '@/lib/gtag';

const NewsletterForm = () => {
  const handleSignup = async (e) => {
    e.preventDefault();
    const email = e.target.email.value;

    try {
      const response = await fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/newsletter-subscribers`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });

      if (response.ok) {
        trackEvent('sign_up', {
          method: 'newsletter',
          form_location: 'footer',
        });
      }
    } catch (error) {
      console.error('Signup error:', error);
    }
  };

  return (
    <form
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
};

Content Interaction Tracking

Track Blog Post Reads

// pages/blog/[slug].js
import { useEffect, useRef } from 'react';
import { trackEvent } from '@/lib/gtag';

export default function BlogPost({ post }) {
  const hasTrackedRead = useRef(false);

  useEffect(() => {
    // Track after 30 seconds on page
    const timer = setTimeout(() => {
      if (!hasTrackedRead.current) {
        trackEvent('page_engagement', {
          content_type: 'blog_post',
          content_id: post.id,
          content_title: post.title,
          engagement_time: 30,
        });
        hasTrackedRead.current = true;
      }
    }, 30000);

    return () => clearTimeout(timer);
  }, [post]);

  return <article>{/* Post content */}</article>;
}

Track File Downloads

import { trackEvent } from '@/lib/gtag';

const DownloadButton = ({ file }) => {
  const handleDownload = () => {
    trackEvent('file_download', {
      file_name: file.filename,
      file_type: file.mimeType,
      file_size: file.filesize,
      link_url: file.url,
    });

    // Trigger download
    window.open(file.url, '_blank');
  };

  return (
    <button
      Download {file.filename}
    </button>
  );
};

Track Video Plays

import { useRef } from 'react';
import { trackEvent } from '@/lib/gtag';

const VideoPlayer = ({ video }) => {
  const videoRef = useRef(null);
  const hasTrackedPlay = useRef(false);
  const hasTracked25 = useRef(false);
  const hasTracked50 = useRef(false);
  const hasTracked75 = useRef(false);

  const handlePlay = () => {
    if (!hasTrackedPlay.current) {
      trackEvent('video_start', {
        video_title: video.title,
        video_url: video.url,
        video_provider: 'self_hosted',
      });
      hasTrackedPlay.current = true;
    }
  };

  const handleTimeUpdate = () => {
    const video = videoRef.current;
    const progress = (video.currentTime / video.duration) * 100;

    if (progress >= 25 && !hasTracked25.current) {
      trackEvent('video_progress', {
        video_title: video.title,
        video_percent: 25,
      });
      hasTracked25.current = true;
    } else if (progress >= 50 && !hasTracked50.current) {
      trackEvent('video_progress', {
        video_title: video.title,
        video_percent: 50,
      });
      hasTracked50.current = true;
    } else if (progress >= 75 && !hasTracked75.current) {
      trackEvent('video_progress', {
        video_title: video.title,
        video_percent: 75,
      });
      hasTracked75.current = true;
    }
  };

  const handleComplete = () => {
    trackEvent('video_complete', {
      video_title: video.title,
      video_url: video.url,
    });
  };

  return (
    <video
      ref={videoRef}
      controls
    >
      <source src={video.url} type="video/mp4" />
    </video>
  );
};

import { trackEvent } from '@/lib/gtag';

const ExternalLink = ({ href, children }) => {
  const handleClick = () => {
    trackEvent('click', {
      link_url: href,
      link_domain: new URL(href).hostname,
      outbound: true,
    });
  };

  return (
    <a href={href} target="_blank" rel="noopener noreferrer"
      {children}
    </a>
  );
};

Track Navigation Menu Clicks

import Link from 'next/link';
import { trackEvent } from '@/lib/gtag';

const NavLink = ({ href, label }) => {
  const handleClick = () => {
    trackEvent('navigation_click', {
      link_text: label,
      link_url: href,
      menu_location: 'primary_nav',
    });
  };

  return (
    <Link href={href}
      {label}
    </Link>
  );
};

Search Tracking

import { useState } from 'react';
import { useRouter } from 'next/router';
import { trackEvent } from '@/lib/gtag';

const SearchBar = () => {
  const [query, setQuery] = useState('');
  const router = useRouter();

  const handleSearch = async (e) => {
    e.preventDefault();

    // Fetch results from Payload
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/posts?where[title][like]=${query}`
    );
    const data = await response.json();

    // Track search
    trackEvent('search', {
      search_term: query,
      search_results: data.docs.length,
    });

    // Navigate to results
    router.push(`/search?q=${query}`);
  };

  return (
    <form
      <input
        type="search"
        value={query} => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <button type="submit">Search</button>
    </form>
  );
};

User Engagement Tracking

Track Scroll Depth

import { useEffect, useRef } from 'react';
import { trackEvent } from '@/lib/gtag';

export default function Post({ post }) {
  const tracked25 = useRef(false);
  const tracked50 = useRef(false);
  const tracked75 = useRef(false);
  const tracked100 = useRef(false);

  useEffect(() => {
    const handleScroll = () => {
      const scrollPercent = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;

      if (scrollPercent >= 25 && !tracked25.current) {
        trackEvent('scroll', { percent_scrolled: 25 });
        tracked25.current = true;
      } else if (scrollPercent >= 50 && !tracked50.current) {
        trackEvent('scroll', { percent_scrolled: 50 });
        tracked50.current = true;
      } else if (scrollPercent >= 75 && !tracked75.current) {
        trackEvent('scroll', { percent_scrolled: 75 });
        tracked75.current = true;
      } else if (scrollPercent >= 100 && !tracked100.current) {
        trackEvent('scroll', { percent_scrolled: 100 });
        tracked100.current = true;
      }
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return <article>{/* Content */}</article>;
}

Track Time on Page

import { useEffect, useRef } from 'react';
import { trackEvent } from '@/lib/gtag';

export function usePageTimer(pageName) {
  const startTime = useRef(Date.now());

  useEffect(() => {
    return () => {
      const timeSpent = Math.round((Date.now() - startTime.current) / 1000);
      trackEvent('page_time', {
        page_name: pageName,
        time_spent: timeSpent,
      });
    };
  }, [pageName]);
}

// Usage in component
export default function BlogPost({ post }) {
  usePageTimer(post.title);

  return <article>{/* Content */}</article>;
}

Enhanced Measurement Events

Track Page Views with Custom Dimensions

import { useEffect } from 'react';
import { trackEvent } from '@/lib/gtag';

export default function Post({ post }) {
  useEffect(() => {
    trackEvent('page_view', {
      page_title: post.title,
      page_location: window.location.href,
      content_type: 'blog_post',
      content_category: post.category?.name,
      author: post.author?.name,
      publish_date: post.publishedDate,
    });
  }, [post]);

  return <article>{/* Content */}</article>;
}

Server-Side Event Tracking

Track Form Submissions via Payload Hooks

// collections/ContactSubmissions.ts
import { CollectionConfig } from 'payload/types';
import { trackServerEvent } from '../lib/server-analytics';

const ContactSubmissions: CollectionConfig = {
  slug: 'contact-submissions',
  hooks: {
    afterChange: [
      async ({ doc, operation, req }) => {
        if (operation === 'create') {
          await trackServerEvent({
            clientId: req.ip || 'server',
            eventName: 'form_submission',
            eventParams: {
              form_name: 'contact',
              submission_method: 'api',
              user_agent: req.headers['user-agent'],
            },
          });
        }
      },
    ],
  },
  fields: [
    { name: 'name', type: 'text' },
    { name: 'email', type: 'email' },
    { name: 'message', type: 'textarea' },
  ],
};

export default ContactSubmissions;

Debugging Events

Enable Debug Mode

// In development
if (process.env.NODE_ENV === 'development') {
  window.gtag('config', process.env.NEXT_PUBLIC_GA_ID, {
    debug_mode: true,
  });
}

Log Events to Console

export const trackEvent = (eventName, eventParams = {}) => {
  if (typeof window !== 'undefined' && window.gtag) {
    // Log in development
    if (process.env.NODE_ENV === 'development') {
      console.log('GA4 Event:', eventName, eventParams);
    }

    window.gtag('event', eventName, eventParams);
  }
};

Best Practices

  1. Use Descriptive Event Names: Follow GA4 recommended event names when possible
  2. Include Context: Add relevant parameters to each event
  3. Avoid Over-Tracking: Don't track every minor interaction
  4. Test Events: Use DebugView to verify events before deployment
  5. Document Events: Maintain a list of custom events and their parameters

Next Steps


Additional Resources