How to Set Up GDPR-Compliant Analytics in Wagtail CMS: Cookie Consent with Clarity and Google Analytics

adonis simoadonis simo
8 min read

Why is it important ?

When creating a website, you include things like Google Analytics to track visits and view some insights later, but that’s called tracking, and it’s important to let the visitor know about it. In the EU, there are laws requiring website owners to add a notice that tells the user about any kind of tracking or cookie usage since those cookies are saved in their browser and are used for tracking them.

I’ve built a website recently using Wagtail CMS, and I wanted to track visits and visualize user actions using Microsoft Clarity. I signed up for those two services and grabbed the integration code. I also wanted to include a banner for cookie consent approval. It's quite simple to build, but there is a catch: when the user decides not to be tracked, I should not trigger Google Analytics or Clarity dynamically. This article is about how I did it on my Wagtail site.

How to do it?

I created a Snippet that contains the code that can be injected into all the pages, for example, the Google Analytics code, and it has an is_active field. The purpose of this model is to store all the codes I want to inject into my webpage. Here is what the code looks like; I called it ThirdPartyIntegration.

# youapp.models.py

@register_snippet
class ThirdPartyIntegration(models.Model):
    name = models.CharField(
        max_length=255,
        help_text="A descriptive name for this integration (e.g., 'Google Analytics', 'Hotjar')"
    )
    html_code = models.TextField(
        help_text="The HTML/JavaScript code to be inserted in the template"
    )
    is_active = models.BooleanField(
        default=True,
        help_text="Only active integrations will be included in the template"
    )
    position = models.CharField(
        max_length=20,
        choices=[
            ('head_start', 'Beginning of HEAD'),
            ('head_end', 'End of HEAD'),
            ('body_start', 'Beginning of BODY'),
            ('body_end', 'End of BODY'),
        ],
        default='head_end',
        help_text="Where in the template this code should be inserted"
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    panels = [
        FieldPanel('name'),
        FieldPanel('html_code'),
        FieldPanel('is_active'),
        FieldPanel('position'),
    ]

    class Meta:
        verbose_name = "Third Party Integration"
        verbose_name_plural = "Third Party Integrations"
        ordering = ['name']

    def __str__(self):
        return f"{self.name} ({'Active' if self.is_active else 'Inactive'})"

It also contains a position to specify exactly where I can include it on the webpage. There are four possible positions.

To render each integration on all pages, I created a Django template tag to load them, but only the active ones.


from django import template
from django.utils.safestring import mark_safe
from yourapp.models import ThirdPartyIntegration 

register = template.Library()


@register.simple_tag
def render_integrations(position):
    """
    Renders all active third-party integrations for a specific position.
    Usage: {% render_integrations 'head_end' %}
    """
    integrations = ThirdPartyIntegration.objects.filter(
        is_active=True,
        position=position
    )
    return mark_safe('\n'.join(integration.html_code for integration in integrations))

This will output a simple set of HTML code that will be injected directly into the template at render time.

Organize across the base template

Next, to inject them into the webpage, I modified the base.html file to call this tag at the four different positions like this:

<!DOCTYPE html>
<html lang="en">
    <head>
    {% render_integrations 'head_start' %}

    <!--- some stuff here -->

    {% render_integrations 'head_end' %}
    </head>

    <body>
    {% render_integrations 'body_start' %}

    <!-- the content here -->
    {% render_integrations 'body_end' %}
    </body>
</html>

Obviously, I removed all the default stuff Wagtail provides in the base template for demo purposes. Now, we have called the template tag to load any third-party scripts dynamically. This is technically enough to not worry anymore about integrating these scripts manually. In my Wagtail admin, I have something that looks like this:

The banner

The consent banner is the most important part here because, depending on what the user chooses, it will let the other scripts run or not, so it has to be written accordingly. Here is its content:

<!-- Cookie Consent Banner -->
<style>
.cookie-consent-banner {
    display: none; /* Hidden by default, shown via JS */
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 9999;
    background-color: #1e293b;
    color: white;
    padding: 1rem;
    box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
}
.cookie-consent-banner.visible {
    display: block;
}
</style>

<div id="cookie-consent-banner" class="cookie-consent-banner border-t border-white">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="md:flex md:items-center md:justify-between">
            <div class="flex-1 min-w-0 mb-4 md:mb-0">
                <p class="text-sm">
                    THE CONSENT TEXT HERE
                </p>
            </div>
            <div class="flex-shrink-0 flex space-x-4">
                <button id="reject-cookies" 
                    class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
                    Refuse All
                </button>
                <button id="accept-cookies" 
                    class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-secondary bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500" style="margin-left: 5px">
                    Accept All
                </button>
            </div>
        </div>
    </div>
</div>

<script>
(function() {
    function initializeCookieConsent() {
        const banner = document.getElementById('cookie-consent-banner');

        // If user already made a choice, don't show the banner
        if (localStorage.getItem('cookieConsentChoice')) {
            return;
        }
        // Show the banner
        if (banner) {
            banner.classList.add('visible');
        }

        // Handle accept button click
        document.getElementById('accept-cookies')?.addEventListener('click', function() {
            acceptCookies();
            hideBanner();
        });

        // Handle reject button click
        document.getElementById('reject-cookies')?.addEventListener('click', function() {
            rejectCookies();
            hideBanner();
        });
    }

    function acceptCookies() {
        localStorage.setItem('cookieConsentChoice', 'accepted');
        // Dispatch custom event that other scripts can listen for
        document.dispatchEvent(new CustomEvent('cookieConsentAccepted'));
    }

    function rejectCookies() {
        localStorage.setItem('cookieConsentChoice', 'rejected');
        // Dispatch custom event that other scripts can listen for
        document.dispatchEvent(new CustomEvent('cookieConsentRejected'));
        // Disable tracking cookies if possible
        disableTracking();
    }

    function hideBanner() {
        const banner = document.getElementById('cookie-consent-banner');
        if (banner) {
            banner.classList.remove('visible');
        }
    }

    function disableTracking() {
        // Add your GA_ID or other tracking IDs here
        const GA_ID = 'G-XXXXXXXXXXX'; // Replace with your actual GA ID
        if (window[`ga-disable-${GA_ID}`]) {
            window[`ga-disable-${GA_ID}`] = true;
        }
        // Clear existing cookies
        document.cookie.split(";").forEach(function(c) { 
            document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); 
        });
    }

    // Create cookie consent manager object
    window.CookieConsentManager = {
        hasConsent: function() {
            return localStorage.getItem('cookieConsentChoice') === 'accepted';
        },
        getChoice: function() {
            return localStorage.getItem('cookieConsentChoice');
        },
        reset: function() {
            localStorage.removeItem('cookieConsentChoice');
            initializeCookieConsent();
        }
    };

    // Initialize when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeCookieConsent);
    } else {
        initializeCookieConsent();
    }
})();
</script>

This code assumes that you will include Google Analytics, which is why the function that runs when the user rejects consent is written this way:

function disableTracking() {
        // Add your GA_ID or other tracking IDs here
        const GA_ID = 'G-XXXXXXXXXXX'; // Replace with your actual GA ID
        if (window[`ga-disable-${GA_ID}`]) {
            window[`ga-disable-${GA_ID}`] = true;
        }
        // Clear existing cookies
        document.cookie.split(";").forEach(function(c) { 
            document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); 
        });
    }

These scripts will simply check if the user has accepted or rejected the consent and enable the other scripts accordingly. They store the acceptance in a cookie and check it every time the page loads. The HTML code is written with the assumption that you use Tailwind CSS in your project, so you should modify it accordingly..

Google Analytics

The following code will start the GA integration script or not, depending on the user’s consent.

<!-- Google Analytics with Consent Check -->
<script>
(function() {
    // Function to initialize Google Analytics
    function initializeGA() {
        window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('js', new Date());
        gtag('config', 'G-XXXXXX');
    }

    // Function to load GA script
    function loadGAScript() {
        const script = document.createElement('script');
        script.async = true;
        script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX';
        document.head.appendChild(script);
        script.onload = initializeGA;
    }

    // Check if user has already consented
    if (window.CookieConsentManager && window.CookieConsentManager.hasConsent()) {
        loadGAScript();
    }

    // Listen for future consent
    document.addEventListener('cookieConsentAccepted', function() {
        loadGAScript();
    });

    // Listen for consent rejection
    document.addEventListener('cookieConsentRejected', function() {
        // Disable GA if it was previously loaded
        if (window['ga-disable-G-XXXXXXXXXX']) {
            window['ga-disable-G-XXXXXXXXXX'] = true;
        }
    });
})();
</script>

It does the following:

  • Listens to the cookieConsentRejected event and disables GA if it was previously enabled.

  • Listens to the cookieConsentAccepted event and enables GA if it was disabled.

The loadGAScript function is the one actually attaching GA to the DOM, and initializeGA will start the tracking process of GA. If you have already included GA integration code, you might be familiar with these two specific code blocks.

Microsoft Clarity

The following code shows how the integration of Clarity was done by following the two previous patterns for checking consent first.

<!-- Microsoft Clarity with Consent Check -->
<script>
(function() {
    const CLARITY_ID = 'xxxxxxxx'; // Replace with your actual Clarity project ID

    // Function to initialize Clarity
    function initializeClarity() {
        (function(c,l,a,r,i,t,y){
            c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
            t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
            y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
        })(window, document, "clarity", "script", CLARITY_ID);
    }

    // Function to disable Clarity
    function disableClarity() {
        // Remove Clarity cookies if they exist
        document.cookie = `_clarity=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
        document.cookie = `_clsk=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
        document.cookie = `_clck=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;

        // Stop any existing Clarity sessions
        if (window.clarity) {
            window.clarity("stop");
        }
    }

    // Check if user has already consented
    if (window.CookieConsentManager && window.CookieConsentManager.hasConsent()) {
        initializeClarity();
    }

    // Listen for future consent
    document.addEventListener('cookieConsentAccepted', function() {
        initializeClarity();
    });

    // Listen for consent rejection
    document.addEventListener('cookieConsentRejected', function() {
        disableClarity();
    });
})();
</script>

If you are familiar with Clarity integration, the function initializeClarity will be a reminder. This code simply listens to events related to consent status and disables or enables Clarity tracking.

I positioned them differently on the page:

  • Clarity and GA scripts are positioned at the head_end.

  • Cookie consent is at the body_end.

Conclusion

Implementing GDPR-compliant analytics in Wagtail CMS demonstrates the platform's exceptional flexibility and extensibility. This approach to cookie consent and analytics tracking has proven robust and reusable across multiple projects, making it a valuable addition to any Wagtail website. The combination of Wagtail's structured architecture with Django's powerful framework provides an ideal foundation for building scalable, privacy-conscious web applications.

As a seasoned Django/Wagtail developer with years of experience building professional websites and applications, I'm available to help you implement similar solutions or develop your next web project. Whether you need assistance with analytics implementation, custom Wagtail development, or a complete website build, feel free to reach out.

1
Subscribe to my newsletter

Read articles from adonis simo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

adonis simo
adonis simo

I’m a Certified Cloud & Software Engineer based in Douala, Cameroon, with over 4 years of experience. I am currently working as a Remote Tech Product Manager at Workbud. I am technically proficient with python, JavaScript and AWS Cloud Platform. In the last couple of years I have been involved in the product management world and I love writing articles and mentoring because I enjoy sharing knowledge.