GA4 Event Tracking for Netlify CMS / Decap CMS Sites | OpsBlu Docs

GA4 Event Tracking for Netlify CMS / Decap CMS Sites

Implement custom event tracking for static sites built with Netlify CMS and Decap CMS

Custom event tracking on static sites requires client-side JavaScript since there's no server-side processing. This guide covers implementing GA4 events across different static site generators used with Netlify CMS.

Event Tracking Fundamentals for Static Sites

Build-Time vs Runtime Events

Build-Time (Template Injection):

  • Content metadata (author, category, publish date)
  • Page types (blog post, landing page, documentation)
  • Static properties (word count, reading time)

Runtime (JavaScript):

  • User interactions (clicks, scrolls, form submissions)
  • Time-based events (time on page, session duration)
  • Dynamic behavior (video plays, tab switches)

Event Naming Conventions

Follow GA4 recommended event structure:

gtag('event', 'event_name', {
  'parameter_1': 'value',
  'parameter_2': 'value'
});

Recommended events for content sites:

  • page_view - Automatic (SPA route changes)
  • scroll - User scrolls 90%
  • click - Outbound links, buttons, CTAs
  • file_download - PDFs, ebooks, resources
  • form_submit - Newsletter, contact forms
  • video_play / video_complete - Embedded videos
  • search - Site search usage
  • share - Social sharing buttons

Common Event Implementations

Track clicks on external links:

// Universal implementation (works on all static site generators)
document.addEventListener('DOMContentLoaded', function() {
  // Select all external links
  document.querySelectorAll('a[href^="http"]').forEach(function(link) {
    // Check if link is external
    if (!link.href.includes(window.location.hostname)) {
      link.addEventListener('click', function(e) {
        gtag('event', 'click', {
          'event_category': 'outbound',
          'event_label': this.href,
          'transport_type': 'beacon',
          'event_callback': function() {
            // Fallback if beacon fails
          }
        });
      });
    }
  });
});

Hugo partial (layouts/partials/analytics-events.html):

<script>
  // Outbound link tracking
  document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('a[href^="http"]').forEach(function(link) {
      if (!link.href.includes(window.location.hostname)) {
        link.addEventListener('click', function() {
          gtag('event', 'click', {
            'event_category': 'outbound',
            'event_label': this.href,
            'link_text': this.textContent,
            'page_location': window.location.pathname
          });
        });
      }
    });
  });
</script>

2. File Download Tracking

Track PDF, ebook, and resource downloads:

// Track all downloadable file types
const downloadExtensions = ['.pdf', '.zip', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'];

document.addEventListener('DOMContentLoaded', function() {
  document.querySelectorAll('a').forEach(function(link) {
    const href = link.href.toLowerCase();
    const hasDownloadableExtension = downloadExtensions.some(ext => href.endsWith(ext));

    if (hasDownloadableExtension) {
      link.addEventListener('click', function() {
        const fileName = this.href.split('/').pop();
        const fileExtension = fileName.split('.').pop();

        gtag('event', 'file_download', {
          'file_name': fileName,
          'file_extension': fileExtension,
          'link_text': this.textContent,
          'link_url': this.href
        });
      });
    }
  });
});

Jekyll include (_includes/analytics-downloads.html):

<script>
  document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('a[href$=".pdf"], a[href$=".zip"], a[href$=".doc"]').forEach(function(link) {
      link.addEventListener('click', function() {
        gtag('event', 'file_download', {
          'file_name': this.href.split('/').pop(),
          'file_extension': this.href.split('.').pop(),
          'page_title': '{{ page.title }}'
        });
      });
    });
  });
</script>

3. Scroll Depth Tracking

Track when users scroll to specific page depths:

// Track 25%, 50%, 75%, and 90% scroll depths
let scrollMarks = {
  25: false,
  50: false,
  75: false,
  90: false
};

function calculateScrollDepth() {
  const windowHeight = window.innerHeight;
  const documentHeight = document.documentElement.scrollHeight;
  const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
  const scrollPercent = Math.floor((scrollTop / (documentHeight - windowHeight)) * 100);

  for (let mark in scrollMarks) {
    if (!scrollMarks[mark] && scrollPercent >= mark) {
      scrollMarks[mark] = true;

      gtag('event', 'scroll', {
        'event_category': 'engagement',
        'event_label': mark + '%',
        'value': parseInt(mark),
        'page_title': document.title
      });
    }
  }
}

// Debounce scroll events (performance optimization)
let scrollTimeout;
window.addEventListener('scroll', function() {
  clearTimeout(scrollTimeout);
  scrollTimeout = setTimeout(calculateScrollDepth, 100);
}, { passive: true });

Gatsby component (src/components/ScrollTracking.js):

import { useEffect, useState } from 'react';

const ScrollTracking = () => {
  const [scrollMarks] = useState({ 25: false, 50: false, 75: false, 90: false });

  useEffect(() => {
    const handleScroll = () => {
      const windowHeight = window.innerHeight;
      const documentHeight = document.documentElement.scrollHeight;
      const scrollTop = window.pageYOffset;
      const scrollPercent = Math.floor((scrollTop / (documentHeight - windowHeight)) * 100);

      Object.keys(scrollMarks).forEach(mark => {
        if (!scrollMarks[mark] && scrollPercent >= parseInt(mark)) {
          scrollMarks[mark] = true;

          if (typeof window.gtag !== 'undefined') {
            window.gtag('event', 'scroll', {
              'event_category': 'engagement',
              'event_label': `${mark}%`,
              'value': parseInt(mark)
            });
          }
        }
      });
    };

    let timeout;
    const debouncedScroll = () => {
      clearTimeout(timeout);
      timeout = setTimeout(handleScroll, 100);
    };

    window.addEventListener('scroll', debouncedScroll, { passive: true });

    return () => {
      window.removeEventListener('scroll', debouncedScroll);
    };
  }, [scrollMarks]);

  return null;
};

export default ScrollTracking;

4. Form Submission Tracking

Track newsletter signups and contact forms:

// Newsletter form tracking
document.addEventListener('DOMContentLoaded', function() {
  const newsletterForm = document.querySelector('#newsletter-form');

  if (newsletterForm) {
    newsletterForm.addEventListener('submit', function(e) {
      e.preventDefault();

      gtag('event', 'generate_lead', {
        'event_category': 'forms',
        'event_label': 'newsletter_signup',
        'form_location': 'sidebar', // or footer, popup, etc.
        'value': 1
      });

      // Then submit form
      this.submit();
    });
  }
});

Next.js component example:

import { useState } from 'react';
import * as gtag from '../lib/analytics';

const NewsletterForm = ({ location = 'footer' }) => {
  const [email, setEmail] = useState('');

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

    // Track conversion
    gtag.event({
      action: 'generate_lead',
      category: 'Newsletter',
      label: location,
      value: 1
    });

    // Submit to API
    try {
      await fetch('/api/subscribe', {
        method: 'POST',
        body: JSON.stringify({ email })
      });
      alert('Subscribed!');
    } catch (error) {
      console.error('Subscription failed', error);
    }
  };

  return (
    <form
      <input
        type="email"
        value={email} => setEmail(e.target.value)}
        placeholder="Enter your email"
        required
      />
      <button type="submit">Subscribe</button>
    </form>
  );
};

export default NewsletterForm;

5. CTA Button Tracking

Track specific call-to-action buttons:

// Track CTA clicks with detailed parameters
document.addEventListener('DOMContentLoaded', function() {
  document.querySelectorAll('.cta-button').forEach(function(button) {
    button.addEventListener('click', function() {
      gtag('event', 'cta_click', {
        'event_category': 'engagement',
        'button_text': this.textContent.trim(),
        'button_location': this.getAttribute('data-location') || 'unknown',
        'button_url': this.href || '',
        'page_title': document.title
      });
    });
  });
});

Hugo shortcode (layouts/shortcodes/cta-button.html):

<a
  href="{{ .Get "url" }}"
  class="cta-button"
  data-location="{{ .Get "location" }}" 'cta_click', {
    'button_text': '{{ .Get "text" }}',
    'button_location': '{{ .Get "location" }}',
    'button_url': '{{ .Get "url" }}'
  });"
>
  {{ .Get "text" }}
</a>

Usage in content:

{{< cta-button url="/pricing" text="Get Started" location="hero" >}}

6. Video Tracking

Track embedded video engagement:

// YouTube video tracking
document.addEventListener('DOMContentLoaded', function() {
  // Load YouTube IFrame API
  const tag = document.createElement('script');
  tag.src = 'https://www.youtube.com/iframe_api';
  const firstScriptTag = document.getElementsByTagName('script')[0];
  firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
});

function onYouTubeIframeAPIReady() {
  const videos = document.querySelectorAll('iframe[src*="youtube.com"]');

  videos.forEach(function(iframe, index) {
    const player = new YT.Player(iframe, {
      events: {
        'onStateChange': function(event) {
          const videoUrl = event.target.getVideoUrl();
          const videoTitle = event.target.getVideoData().title;

          // Track play
          if (event.data === YT.PlayerState.PLAYING) {
            gtag('event', 'video_start', {
              'event_category': 'video',
              'event_label': videoTitle,
              'video_url': videoUrl
            });
          }

          // Track complete
          if (event.data === YT.PlayerState.ENDED) {
            gtag('event', 'video_complete', {
              'event_category': 'video',
              'event_label': videoTitle,
              'video_url': videoUrl
            });
          }
        }
      }
    });
  });
}

7. Search Tracking

Track site search usage:

// Track search form submissions
document.addEventListener('DOMContentLoaded', function() {
  const searchForm = document.querySelector('#search-form');

  if (searchForm) {
    searchForm.addEventListener('submit', function(e) {
      const searchInput = this.querySelector('input[name="q"], input[name="search"]');
      const searchTerm = searchInput ? searchInput.value : '';

      gtag('event', 'search', {
        'search_term': searchTerm
      });
    });
  }
});

11ty implementation:

<!-- _includes/search-form.njk -->
<form id="search-form" action="/search" method="get">
  <input type="text" name="q" placeholder="Search...">
  <button type="submit">Search</button>
</form>

<script>
  document.getElementById('search-form').addEventListener('submit', function() {
    const searchTerm = this.querySelector('input[name="q"]').value;
    gtag('event', 'search', { 'search_term': searchTerm });
  });
</script>

8. Social Share Tracking

Track social media sharing:

// Track social share clicks
document.addEventListener('DOMContentLoaded', function() {
  document.querySelectorAll('.social-share-button').forEach(function(button) {
    button.addEventListener('click', function() {
      const platform = this.getAttribute('data-platform');

      gtag('event', 'share', {
        'method': platform, // 'twitter', 'facebook', 'linkedin'
        'content_type': 'article',
        'item_id': window.location.pathname
      });
    });
  });
});

Jekyll include (_includes/social-share.html):

<div class="social-share">
  <a href="https://twitter.com/intent/tweet?url={{ page.url | absolute_url }}&text={{ page.title }}"
     class="social-share-button"
     data-platform="twitter"
     target="_blank" 'share', {'method': 'twitter', 'content_type': 'article'});">
    Share on Twitter
  </a>

  <a href="https://www.facebook.com/sharer/sharer.php?u={{ page.url | absolute_url }}"
     class="social-share-button"
     data-platform="facebook"
     target="_blank" 'share', {'method': 'facebook', 'content_type': 'article'});">
    Share on Facebook
  </a>

  <a href="https://www.linkedin.com/shareArticle?mini=true&url={{ page.url | absolute_url }}&title={{ page.title }}"
     class="social-share-button"
     data-platform="linkedin"
     target="_blank" 'share', {'method': 'linkedin', 'content_type': 'article'});">
    Share on LinkedIn
  </a>
</div>

Framework-Specific Implementations

Gatsby Event Tracking

Using React hooks:

// src/hooks/useAnalytics.js
import { useEffect } from 'react';

export const useAnalytics = () => {
  const trackEvent = (eventName, eventParams) => {
    if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
      window.gtag('event', eventName, eventParams);
    }
  };

  return { trackEvent };
};

// Usage in component
import { useAnalytics } from '../hooks/useAnalytics';

const DownloadButton = ({ fileName }) => {
  const { trackEvent } = useAnalytics();

  const handleClick = () => {
    trackEvent('file_download', {
      'file_name': fileName,
      'file_extension': fileName.split('.').pop()
    });
  };

  return <button {fileName}</button>;
};

Next.js Event Tracking

Using custom hook:

// hooks/useTracking.js
import { useCallback } from 'react';
import * as gtag from '../lib/analytics';

export const useTracking = () => {
  const trackClick = useCallback((label, category = 'engagement') => {
    gtag.event({
      action: 'click',
      category,
      label
    });
  }, []);

  const trackDownload = useCallback((fileName) => {
    gtag.event({
      action: 'file_download',
      category: 'downloads',
      label: fileName
    });
  }, []);

  const trackFormSubmit = useCallback((formName) => {
    gtag.event({
      action: 'generate_lead',
      category: 'forms',
      label: formName
    });
  }, []);

  return { trackClick, trackDownload, trackFormSubmit };
};

// Usage
import { useTracking } from '../hooks/useTracking';

const ContactForm = () => {
  const { trackFormSubmit } = useTracking();

  const handleSubmit = (e) => {
    e.preventDefault();
    trackFormSubmit('contact_form');
    // Submit logic...
  };

  return <form
};

Content Metadata Events

Build-Time Content Properties

Inject content metadata when pages are built:

Hugo example:

<!-- layouts/_default/single.html -->
<script>
  // Track content metadata on page load
  gtag('event', 'page_view', {
    'content_type': '{{ .Type }}',
    'content_category': '{{ .Section }}',
    'author': '{{ .Params.author }}',
    'publish_date': '{{ .Date.Format "2006-01-02" }}',
    'word_count': {{ .WordCount }},
    'reading_time': {{ .ReadingTime }},
    {{ if .Params.tags }}'tags': {{ .Params.tags | jsonify }}{{ end }}
  });
</script>

Jekyll example:

<!-- _layouts/post.html -->
<script>
  gtag('event', 'page_view', {
    'content_type': 'blog_post',
    'content_category': '{{ page.categories | first }}',
    'author': '{{ page.author }}',
    'publish_date': '{{ page.date | date: "%Y-%m-%d" }}',
    'word_count': {{ page.content | number_of_words }}
  });
</script>

Event Tracking Best Practices

1. Debounce High-Frequency Events

Prevent excessive event firing (especially scroll events):

function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

// Usage
const handleScroll = debounce(() => {
  // Track scroll
}, 100);

window.addEventListener('scroll', handleScroll, { passive: true });

2. Use Event Callback for Navigation

Ensure events fire before page navigation:

link.addEventListener('click', function(e) {
  e.preventDefault();
  const destination = this.href;

  gtag('event', 'click', {
    'event_category': 'outbound',
    'event_label': destination,
    'event_callback': function() {
      window.location = destination;
    }
  });

  // Fallback timeout
  setTimeout(function() {
    window.location = destination;
  }, 1000);
});

3. Respect User Privacy

Don't track sensitive information:

// BAD - Don't track PII
gtag('event', 'form_submit', {
  'user_email': email, // Don't do this!
  'user_name': name     // Don't do this!
});

// GOOD - Track form usage without PII
gtag('event', 'form_submit', {
  'form_name': 'contact',
  'form_location': 'footer'
});

4. Test Events Locally

Use console logging during development:

function trackEvent(eventName, eventParams) {
  if (process.env.NODE_ENV === 'development') {
    console.log('GA4 Event:', eventName, eventParams);
  } else {
    gtag('event', eventName, eventParams);
  }
}

Testing Event Tracking

1. DebugView in GA4

Enable debug mode:

gtag('config', 'G-XXXXXXXXXX', {
  'debug_mode': true
});

View events in real-time:

  • GA4 → Configure → DebugView
  • Events appear within seconds
  • See event parameters and values

2. Browser Console

Check if events are firing:

// Override gtag to log events
const originalGtag = window.gtag;
window.gtag = function() {
  console.log('GA4 Event:', arguments);
  originalGtag.apply(window, arguments);
};

3. Network Tab

Monitor GA4 requests:

  1. Open DevTools → Network
  2. Filter by "collect" or "google-analytics"
  3. Click tracked element
  4. Verify request sent to GA4

Next Steps