You wired up Google Tag Manager. It injects Google Analytics, Microsoft Clarity, maybe AdSense. Every one of them drops cookies, and under GDPR you need consent before they fire — not after. Astro gives you nothing out of the box for this. Here’s the setup I landed on, and it’s smaller than you’d expect.
The model: signal, then flip
The trick is to stop thinking about “blocking scripts”. You don’t block GTM. You let it load, but you tell Google’s tags they have no consent yet, so they sit in a limited mode that sets nothing. When the user accepts, you flip the signal and the tags wake up.
That signal is Google Consent Mode v2 — a handful of flags you push into the data layer through the gtag function: one for analytics storage, the rest for ad storage and ad personalization. You default them all to denied, then update them to granted when the user accepts. The Google Analytics tag that GTM injects watches the analytics flag; AdSense watches the ad flags. One mechanism covers all of them.
So the whole job is three pieces: set the denied defaults early, show a banner, and bridge the banner’s answer back to the consent flags.
Step one: denied defaults, before anything else
This has to run before GTM loads, so it goes high in the document head as an inline script. Order is the entire point — if the defaults land after the tags, you’ve already leaked.
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500,
});
</script>That wait_for_update value tells the Google tags to hold for half a second in case a stored choice is about to flip them. It stops a flicker where a returning visitor who already consented briefly gets the denied state.
Step two: a banner that isn’t a platform
You don’t need a commercial consent platform for this. I used vanilla-cookieconsent — one npm package, no React, no external calls. Define your categories, and wire its callbacks to a single bridge function.
import * as CookieConsent from 'vanilla-cookieconsent';
function updateConsent() {
const analytics = CookieConsent.acceptedCategory('analytics');
const ads = CookieConsent.acceptedCategory('ads');
gtag('consent', 'update', {
analytics_storage: analytics ? 'granted' : 'denied',
ad_storage: ads ? 'granted' : 'denied',
ad_user_data: ads ? 'granted' : 'denied',
ad_personalization: ads ? 'granted' : 'denied',
});
}
CookieConsent.run({
categories: {
necessary: { enabled: true, readOnly: true },
analytics: { autoClear: { cookies: [{ name: /^_ga/ }, { name: /^_clck/ }] } },
ads: { autoClear: { cookies: [{ name: /^__gads/ }] } },
},
onFirstConsent: updateConsent,
onConsent: updateConsent,
onChange: updateConsent,
});Reading the accepted category is the whole bridge. The user’s choice becomes a Consent Mode update, and the Google tags react. The banner also wipes the relevant cookies if someone later revokes — its autoClear rules drop Google Analytics’s tracking cookies, Clarity’s session cookies, and so on.
Step three: wiring it into Astro
Astro has a catch here. If your pages each build their own page head instead of sharing a layout, there’s no single place to inject this. The clean fix is a small component you drop into every page’s head.
---
// Consent.astro
---
<script>
/* the denied-defaults inline script from step one */
</script>
<script>
import '@/scripts/cookieconsent-config';
</script>The first script stays inline, so Astro leaves it exactly where it sits — early, before GTM. The second is a normal bundled import, so the banner code gets compiled and tree-shaken like everything else. Drop that component into each page and you’re done.
The parts Consent Mode doesn’t cover
Two honest caveats, because this is where shortcuts bite.
Consent Mode is a Google protocol. Microsoft Clarity ignores it. If Clarity rides in through GTM too, gate its tag on a consent-granted trigger inside GTM, or it’ll record sessions regardless of what the banner says.
And for ads served to visitors in the EEA, the UK, or Switzerland, Google now demands a certified CMP that emits an IAB TCF string — for any ads there, personalized or not. vanilla-cookieconsent isn’t one, so on its own it won’t keep ads flowing to those regions; Google can limit or stop serving there entirely. It’s not “personalized off, general ads on” — no certified CMP means no reliable ad serving in those regions at all. With a certified CMP in place, a reject simply downgrades to limited, non-personalized ads. Everywhere else, this setup is the whole thing.
That’s it. No platform, no per-script blocking, no monthly fee — a denied default, a small banner, and one function that translates a click into a consent flag.