Index
Introduction
I’m writing this because, while implementing basic GA4 analytics is easy, there are a lot of gotchas when it comes to implementing:
- Consent management
- View transitions
- CookieConsent V3
So I wrote this guide with multiple implementations, from easy to advanced, to help you integrate Google Analytics (GA4) with Astro.
0. Get your GA4 tracking ID
If you don’t have it yet, follow these steps to get your GA4 tracking ID:
-
Go to Google Analytics and sign in with your Google account (or create one).
-
In Admin, click Create, then select Account.
-
Provide an account name and click Next.
-
Create a new Google Analytics 4 property.
-
Fill in the requested data and hit Create.
-
It will prompt you with a data stream, which will provide you with the tracking ID. It should have the format
G-XXXXXXXXXX
. Note this down and continue!
1. Basic implementation
Note:
Usually, in Astro applications, we have a main
Layout.astro
component that we then use in all pages. This is super convenient because we can add the GA4 script, amongst many other common things like meta tags, to thehead
tag of this component and it will be included in all pages.If you don’t have a
Layout.astro
component, I really recommend you create one. It will make your life easier in the long run.
The basic implementation is quite straightforward and you can follow the code that Google provides to you when you create the property data stream.
You just need to add the GA4 script to your head
tag.
<!-- Google tag (gtag.js) -->
<script
is:inline
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
></script>
<script is:inline>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-XXXXXXXXXX");
</script>
Remember to change G-XXXXXXXXXX
with your tracking ID!
Notice that we are using the
is:inline
attribute in the script tags.This is so that Astro ignores all the optimizations it does to scripts and just includes them as they are. This is important because we want to include the GA4 script as is, without any modifications.
That’s it! See how easy it was?
2. Implementing Partytown
What is Partytown?
Partytown is a library that allows you to run third-party scripts in a Web Worker. This is great because it allows you to run scripts like Google Analytics in a separate thread, which can improve performance quite a bit.
To use this you first need to install partytown in your Astro project.
Installation:
- Install Partytown:
npm install partytown
- Add the following to your
astro.config.mjs
:
import { partytown } from "astro/config";
export default defineConfig({
// ...
integrations: [partytown({ config: { forward: ["dataLayer.push"] } })],
});
Then you can add the following code to your Layout.astro
component:
<!-- Google tag (gtag.js) -->
<script
is:inline
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
></script>
<script is:inline type="text/partytown">
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-XXXXXXXXXX");
</script>
The catch
The two previous implementations have a catch though.
No consent
These will track all analytics as this implementation does not include consent management. This means you won’t be GDPR compliant, and can get you in trouble in some countries.
How do we solve this?
This issue can be solved 2 ways:
1. Implement default denied consents and ONLY collect anonymous data.
In your GA4 script tags, you can add the following code:
<!-- Google tag (gtag.js) -->
<script
is:inline
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
></script>
<script is:inline type="text/partytown">
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
// This should ALWAYS be set before the gtag("config", "G-XXXXXXXXXX"); line
gtag("consent", "default", {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied",
});
gtag("config", "G-XXXXXXXXXX");
</script>
2. Implement what is called a “cookie consent banner”.
You’ve seen these everywhere, they are those annoying popups that you either need to accept or decline when you visit a website.
They are indeed annoying, but they are also necessary to avoid legal issues.
Continue reading to see how to implement this with CookieConsent V3.
2. Implementing GA4 with CookieConsent V3
I freaking love CookieConsent ❤️
It’s a super easy to use library that allows you to implement a cookie consent banner in a few lines of code.
I’ll show you 3 ways of implementing this, the easy way, the easy way but with consents and the advanced way with consents.
2.1 The easy way
Head over to https://playground.cookieconsent.orestbida.com/ and poke around the different parameters until you have a cookie consent banner that you like. Make sure you have the “Analytics” option checked under “Categories”.
Then, at the bottom you’ll see a “Download the cookieconsent-config.js
javascript file” button. Click it and download the file.
Place this file in the src
directory of your Astro project. I specifically like putting mine in src/scripts/
so I assume you do the same.
Then, in your Layout.astro
component, add the following code:
<head>
... some code ...
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orestbida/[email protected]/dist/cookieconsent.css"
/>
... some more code ...
</head>
<body>
... some code ...
<script type="module" src="/src/scripts/cookieconsent-config.js"></script>
... some more code ...
</body>
Now back to the GA4 implementation in Layout.astro
(I assume you went with the Partytown implementation), you need to add data-category="analytics"
to the script tags.
<!-- Google tag (gtag.js) -->
<script
data-category="analytics"
is:inline
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
></script>
<script data-category="analytics" is:inline type="text/partytown">
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-XXXXXXXXXX");
</script>
What does this do?
This tells CookieConsent that the scripts with data-category="analytics"
are analytics scripts and should only be run if the user has accepted the analytics category. Pretty nice right?
2.2 The easy way with consents
What do I mean by consents?
In the previous implementation we do not execute the GA4 scripts if or until the user has accepted the analytics category. This will prevent any data collection until the user has accepted the analytics category.
Google allows us to actually collect some anonymous data while being GDPR compliant. This data, like page visits, will be collected anonymously and will not be used for any tracking purposes.
Then, if the user accepts the analytics category, we can collect more data like user interactions, etc.
Cool! How do I do this?
It’s easy:
- Initialize the GA4 scripts with default consents:
```html
<!-- Google tag (gtag.js) -->
<script
is:inline
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
></script>
<script is:inline type="text/partytown">
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
<!-- This is the important part! -->
gtag("consent", "default", {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied",
});
gtag("config", "G-XXXXXXXXXX");
</script>
Note we removed the
data-category="analytics"
from the first script tag, since now we don’t need to wait for the user to accept the analytics category to collect anonymous data.
⚠️ The consent defaults should ALWAYS be set before the
gtag("config", "G-XXXXXXXXXX");
line.
- Create a function to update the consents when the user accepts the analytics category in
<head>
:
<script is:inline data-category="analytics">
function updateConsents() {
gtag("consent", "update", {
ad_storage: "granted",
ad_user_data: "granted",
ad_personalization: "granted",
analytics_storage: "granted",
});
}
</script>
With this implementation we update the consents when the user accepts the analytics category.
2.3 The advanced way with consents
⚠️ IMPORTANT:
Note that for this you will need to uninstall Partytown or at least remove its datalayer configuration in
astro.config.mjs
.This is because Partytown doesn’t work well with this implementation.
If you want to have more control over the cookie consent banner, you can implement it by directly installing the library.
The CookieConsent owner has a pretty amazing StackBlitz example that you can use as a base. I will explain here what he’s done:
2.3.1. Install the library:
npm install vanilla-cookieconsent
2.3.2. Create the necessary files
Create a CookieConsentConfig.ts
and a CookieConsent.astro
in your src/components
directory. I like creating this specifically under src/components/cookie-consent
just to have it organized. From now one I will assume you do the same.
2.3.3. In CookieConsentConfig.ts
:
import type { CookieConsentConfig } from "vanilla-cookieconsent";
export const config: CookieConsentConfig = {
guiOptions: {
consentModal: {
layout: "box inline",
position: "bottom left",
},
preferencesModal: {
layout: "box",
position: "right",
equalWeightButtons: true,
flipButtons: false,
},
},
categories: {
necessary: {
readOnly: true,
},
functionality: {},
analytics: {
services: {
ga4: {
label:
'<a href="https://marketingplatform.google.com/about/analytics/terms/us/" target="_blank">Google Analytics 4 (dummy)</a>',
onAccept: () => {
console.log("ga4 accepted");
// TODO: load ga4
},
onReject: () => {
console.log("ga4 rejected");
},
cookies: [
{
name: /^_ga/,
},
],
},
another: {
label: "Another one (dummy)",
},
},
},
},
language: {
default: "en",
autoDetect: "browser",
translations: {
en: {
consentModal: {
title: "Hello traveller, it's cookie time!",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
acceptAllBtn: "Accept all",
acceptNecessaryBtn: "Reject all",
showPreferencesBtn: "Manage preferences",
footer:
'<a href="#link">Privacy Policy</a>\n<a href="#link">Terms and conditions</a>',
},
preferencesModal: {
title: "Consent Preferences Center",
acceptAllBtn: "Accept all",
acceptNecessaryBtn: "Reject all",
savePreferencesBtn: "Save preferences",
closeIconLabel: "Close modal",
serviceCounterLabel: "Service|Services",
sections: [
{
title: "Cookie Usage",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
},
{
title:
'Strictly Necessary Cookies <span class="pm__badge">Always Enabled</span>',
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
linkedCategory: "necessary",
},
{
title: "Functionality Cookies",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
linkedCategory: "functionality",
},
{
title: "Analytics Cookies",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
linkedCategory: "analytics",
},
{
title: "More information",
description:
'For any query in relation to my policy on cookies and your choices, please <a class="cc__link" href="#yourdomain.com">contact me</a>.',
},
],
},
},
},
},
};
2.3.4. In CookieConsent.astro
:
---
import 'vanilla-cookieconsent/dist/cookieconsent.css';
import '../styles/ccElegantBlack.css';
---
<button type="button" data-cc="show-preferencesModal">
Show preferences modal
</button>
<script>
import { run } from "vanilla-cookieconsent";
import { config } from "./CookieConsentConfig";
run(config);
</script>
2.3.5. Back in your Layout.astro
:
---
// ... some code ...
import CookieConsent from "../components/CookieConsent.astro";
---
...
<body>
<slot />
<CookieConsent />
</body>
...
2.36. Now let’s modify those GA4 script tags so they are a bit more dynamic. Back in your Layout.astro
:
Remove the previous GA4 scripts if you had any and add the following:
<head>
<!-- ... some code ... -->
<!-- GA4 -->
<script
is:inline
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
// We need a global definition to avoid type warnings
declare global {
interface Window {
dataLayer: Record<string, any>[];
gtag: (...args: any[]) => void;
}
}
// Initialize dataLayer
window.dataLayer = window.dataLayer || [];
// Define gtag function
window.gtag = function gtag(...args: any[]) {
window.dataLayer.push(arguments);
};
// Load GA4 with denied default consents
window.gtag("js", new Date());
window.gtag("consent", "default", {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied",
});
window.gtag("config", "G-XXXXXXXXXX");
</script>
<!-- ... some more code ... -->
</head>
2.37. And finally, we create an action in CookieConsentConfig.ts
to update the consents:
import type { CookieConsentConfig } from "vanilla-cookieconsent";
// Extend the Window interface to include the dataLayer object
declare global {
interface Window {
dataLayer: Record<string, any>[];
gtag: (...args: any[]) => void;
}
}
export const config: CookieConsentConfig = {
// ...
analytics: {
enabled: true,
services: {
ga: {
label: "Google Analytics",
onAccept: () => {
// Grant consent to the Google Analytics service
console.log("ga4 granted");
window.gtag("consent", "update", {
ad_storage: "granted",
ad_user_data: "granted",
ad_personalization: "granted",
analytics_storage: "granted",
});
},
onReject: () => {
// Don't enable Google Analytics
console.log("ga4 rejected");
},
// ...
This implementation does not differ too much from the easy way with consents, but it gives you a couple of benefits:
- First you have type safety in your
CookieConsentConfig.ts
file. Meaning you will see warnings if you mess up the configuration. - You have more control over the cookie consent banner. You can declare specific services and actions when the user accepts or rejects them.
- You can have a more dynamic implementation of the GA4 scripts. I.e.(forshadowing) you can use it with view transitions.
3. Implement CookieConsent with View Transitions
To make this work you will need to part from the implementation we’ve done in 2.3.
The caviat here is that Astro View Transitions do 2 things that affect the GA4 scripts:
- They reset scripts on every page change. Meaning that the scripts will run ONCE on first load, but not on subsequent page changes.
- They also reset the html attributes and CookieConsent uses these to keep track of the user’s consents.
To solve this we need to do a couple of things (remember you need to implement everything in 2.3 first):
3.1 Run the GA4 scripts on every page change.
To do this we need to add an event listener on astro:page-load
that will run the GA4 scripts on every page change.
In your Layout.astro
:
<head>
<!-- GA4 -->
<script
is:inline
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
declare global {
interface Window {
dataLayer: Record<string, any>[];
gtag: (...args: any[]) => void;
}
}
// This is the important part!
document.addEventListener("astro:page-load", () => {
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(...args: any[]) {
window.dataLayer.push(arguments);
};
window.gtag("js", new Date());
window.gtag("consent", "default", {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied",
});
window.gtag("config", "G-XXXXXXXXXX");
});
</script></head
>
Now the GA4 scripts will run on every page load and change, cool!
3.2 Put the CookieConsent component inside an element that is not affected by the view transitions.
In your Layout.astro
:
<body>
<slot />
<div transition:persist="find-me-on-the-other-side" id="cc-container">
<CookieConsent />
</div>
</body>
⚠️ Note that we set the
transition:persist
attribute tofind-me-on-the-other-side
. This is just a dummy value, you can put whatever you want here.If we didn’t put anything in there, Astro would get confused when using
transition:name
in other components as it won’t know what transition to ignore here.
In your CookieConsentConfig.ts
:
export const config: CookieConsentConfig = {
// Indicate the consent to live in the #cc-container element
root: "#cc-container",
3.3 Create a script to get the CookieConsent html class attributes and restore them on every page change.
Back in your Layout.astro
:
<body>
<slot />
<div transition:persist="find-me-on-the-other-side" id="cc-container">
<CookieConsent />
<script is:inline>
// Restore the `show--consent` class if it was present before the page swap
document.addEventListener("astro:before-preparation", event => {
const htmlClassName = window.document.documentElement.className;
const consentClassPresent = htmlClassName.includes("show--consent")
? true
: false;
window._showConsentClass = consentClassPresent;
});
document.addEventListener("astro:before-swap", event => {
const showConsent = window._showConsentClass ? ` show--consent` : "";
event.newDocument.documentElement.className += showConsent;
});
</script>
</div>
</body>
And that’s it! You now have a fully working implementation of GA4 with CookieConsent. You can now track your users and be GDPR compliant.