Cloudflare Zaraz can run your consent without its own dialog. Set consent.hideModal: true, ask with your own UI, and record the answers with zaraz.consent.set(). Zaraz still decides which tools run.
That's how my site works now. I really like the new consent prompt. It asks two questions, one at a time, and a small bot reacts to each answer. I think it's a better solution than the general static forms most sites show.
Behind it: GA4, PostHog, Google Ads and Microsoft Advertising, all gated by Zaraz. The site is Astro 7 on Cloudflare Workers. The code below is a minimal, generic version of what runs there, and it says what it leaves out.
Why a prompt instead of a form
A static consent form shows everything at once: legal text, toggles, a row of buttons. The EDPB's cookie banner taskforce listed what goes wrong: no refusal on the first layer, pre-ticked boxes, misleading refusal links, colours and contrast that push one answer (report). My prompt:
- Asks one question at a time, in plain words and in the page's language: "Can I see how you use the site?", then "Can ad platforms count visits that came from an ad?"
- Shows No and Yes as the same button, same size, side by side, No first. Each question takes one click, so refusing everything takes two, the same as accepting everything. A Reject all banner does it in one. CNIL says refusing should be as easy as accepting (CNIL).
- Is a non-modal card docked at the bottom. The page stays usable while it asks.
- Has a bot that looks at No and Yes with the same interest while you decide and is never sad about a No. A Yes gets a smile, a No a calm face. At the end it waves if anything was allowed and nods if both answers were No.
- Says what it saved and where to change it.
- Keeps no consent of its own. Zaraz stays the only consent authority.
How Zaraz consent works
Zaraz is Cloudflare's third-party tool manager, on every plan (docs). Catalog tools run as Managed Components, and the browser loads a lightweight loader from your domain, /cdn-cgi/zaraz/i.js (manual loading). It isn't all server-side: a component can ask the browser to run JavaScript, make requests or set cookies (Managed Components).
Consent works by purpose (consent management):
- You create purposes and assign each tool to one.
- A tool waits until its purpose is granted.
- A tool with no purpose skips consent checks. Adding a tool doesn't give it a consent requirement.
- Choices live in a first-party cookie mapping purpose IDs to
trueorfalse. The cookie name is configurable; Cloudflare's example usescf_consent. - By default, Zaraz shows its modal to anyone who hasn't chosen.
consent.hideModal: truein the rootconsentobject of the configuration stops that, and the Consent API keeps working (config schema).
The Consent API
All of it (Consent API):
zaraz.consent.APIReady // boolean, read-only
zaraz.consent.purposes // read-only: id, name, description, order
zaraz.consent.get(purposeId) // true, false, or undefined for an unknown purpose
zaraz.consent.getAll() // every purpose's state
zaraz.consent.set({ [purposeId]: true }) // updates only the purposes you pass; returns undefined
zaraz.consent.setAll(false) // updates every purpose
zaraz.consent.sendQueuedEvents() // sends pageview-based events withheld for lack of consent
zaraz.consent.modal = true // opens Zaraz's own modal
document.addEventListener('zarazConsentAPIReady', onReady)
document.addEventListener('zarazConsentChoicesUpdated', onChange)
Two things to know. The setters return undefined, not a receipt. And sendQueuedEvents() only sends the withheld pageview-based events. It isn't documented to replay zaraz.track() calls from before the answer.
A two-question prompt
The markup. transition:persist is Astro's; drop it elsewhere:
<!-- In the layout, so it's on every page -->
<section id="consent" aria-label="Privacy choices" hidden transition:persist>
<div aria-live="polite">
<p id="consent-text" tabindex="-1"></p>
<p id="consent-detail"></p>
</div>
<p id="consent-error" role="alert"></p>
<div id="consent-answers">
<!-- One element and one class for both answers: No is never the smaller option -->
<button type="button" class="consent-answer" data-answer="no">No</button>
<button type="button" class="consent-answer" data-answer="yes">Yes</button>
</div>
</section>
<!-- In the footer -->
<button type="button" data-privacy-preferences>Privacy preferences</button>
The script is one module, in four parts. Purpose IDs and two helpers:
const ANALYTICS = 'your-analytics-purpose-id'
const ADS = 'your-ads-purpose-id'
const COOKIE = 'cf_consent' // the consent cookie name in your Zaraz settings
const $ = (id) => document.getElementById(id)
function whenConsentReady(fn) {
if (window.zaraz?.consent?.APIReady) fn()
else document.addEventListener('zarazConsentAPIReady', fn, { once: true })
}
// Ask only when no choice is stored yet
function hasChoice() {
return document.cookie.split(';').some((part) => part.trim().startsWith(`${COOKIE}=`))
}
Google Consent Mode v2
Zaraz supports Consent Mode v2 for GA4 and Google Ads (docs). Set denied defaults in the dashboard (Set Google Consent Mode v2 state) so they apply before tools load. Purposes don't map to Google's categories on their own, so send the stored choice yourself: when the API is ready, so a returning visitor's choice reaches Google, whenever the choice changes, and right after a save.
let lastGoogleState = ''
function syncGoogleConsent() {
const all = zaraz.consent.getAll()
const analytics = all[ANALYTICS] === true
const ads = all[ADS] === true
const state = {
analytics_storage: analytics ? 'granted' : 'denied',
ad_storage: ads ? 'granted' : 'denied',
ad_user_data: ads ? 'granted' : 'denied',
ad_personalization: 'denied' // this example's policy: no question covers personalized ads
}
const key = JSON.stringify(state)
if (key === lastGoogleState) return // send only real changes
lastGoogleState = key
zaraz.set('google_consent_update', state)
}
Hardcoding ad_personalization to denied is this example's policy: neither question covers personalized ads. The Google state is a signal. The purpose gate is what stops a tool from running. You need both.
Asking and saving
Answers stay in memory until the second question, then go to Zaraz in one call. A failed save shows a message and keeps the last question open:
const QUESTIONS = [
{
purpose: ANALYTICS,
text: 'Can I see how you use the site?',
detail: 'Name the analytics tools and what they store.'
},
{
purpose: ADS,
text: 'Can ad platforms count visits that came from an ad?',
detail: 'Name the ad platforms and what they store.'
}
]
let step = 0
let answers = {}
let opener = null
let closeTimer = 0
function ask(index) {
step = index
$('consent-text').textContent = QUESTIONS[index].text
$('consent-detail').textContent = QUESTIONS[index].detail
}
function openPrompt(byUser) {
clearTimeout(closeTimer)
answers = {}
opener = byUser ? document.activeElement : null
$('consent-error').textContent = ''
$('consent-answers').hidden = false
$('consent').hidden = false
ask(0)
if (byUser) $('consent-text').focus()
}
function save() {
try {
zaraz.consent.set(answers) // both answers in one call
} catch {
return false
}
syncGoogleConsent() // Google's state first, then the held-back pageviews
if (Object.values(answers).includes(true)) zaraz.consent.sendQueuedEvents()
return true
}
function answer(yes) {
answers[QUESTIONS[step].purpose] = yes
if (step < QUESTIONS.length - 1) return ask(step + 1)
if (!save()) {
// Nothing was recorded: say so, and let the last answer be given again
$('consent-error').textContent = "Your choice wasn't saved. Please answer again."
return
}
const hadFocus = $('consent').contains(document.activeElement)
$('consent-text').textContent = 'Saved. Change it any time under Privacy preferences.'
$('consent-detail').textContent = ''
$('consent-error').textContent = ''
$('consent-answers').hidden = true
if (hadFocus) $('consent-text').focus()
closeTimer = setTimeout(() => {
const focusInside = $('consent').contains(document.activeElement)
$('consent').hidden = true
if (focusInside) (opener ?? document.querySelector('[data-privacy-preferences]'))?.focus()
}, 2500)
}
Wiring it into Astro
// Astro runs a component's bundled script once per full page load, so these listeners never pile up
document.addEventListener('click', (event) => {
const target =
event.target instanceof Element &&
event.target.closest('[data-answer], [data-privacy-preferences]')
if (!target || !window.zaraz?.consent?.APIReady) return
if (target.hasAttribute('data-privacy-preferences')) openPrompt(true)
else answer(target.dataset.answer === 'yes')
})
// A change made anywhere, Zaraz's own modal included
document.addEventListener('zarazConsentChoicesUpdated', syncGoogleConsent)
// After the first load and after every client-side navigation
document.addEventListener('astro:page-load', () =>
whenConsentReady(() => {
syncGoogleConsent()
if (!hasChoice() && $('consent').hidden) openPrompt(false)
})
)
With <ClientRouter />, the document isn't replaced on navigation. astro:page-load fires after the first load and after each client-side navigation, and transition:persist keeps an open question on screen across one (view transitions). Outside Astro, run that callback once on load.
Left out: the bot and its animation, a summary naming what was turned on, and stepping aside while Zaraz's own modal is open, which only matters if that modal can still open. If Zaraz doesn't load, the prompt never appears and nothing optional runs.
Getting events to each tool
Consent and delivery are separate. A purpose decides whether a tool may run; a trigger and an action decide what it receives (triggers, actions).
| Tool | Purpose | Set up in Zaraz | What it receives |
|---|---|---|---|
| GA4 | Analytics | Catalog tool, with your measurement ID (G-XXXXXXXXXX) |
Page views and events, through actions |
| PostHog | Analytics | Custom HTML tool with the loader below | The SDK's own capture calls |
| Google Ads | Advertising measurement | Catalog tool, with your Google Ads conversion ID | A pageview action |
| Microsoft Advertising | Advertising measurement | Catalog tool listed as Bing, with your UET tag ID | A pageview action |
Page views come from Zaraz's built-in Pageview event: give each catalog tool a pageview action on the Pageview trigger. Don't mix up Pageview (Zaraz's event), zaraz.track('page_view') (a custom event you named) and the pageview action type. GA4 turns a pageview action into page_view; Bing turns it into pageLoad, and an event action into custom (Bing component).
A custom event goes through zaraz.track(name, props) with a flat props object (Track API). PostHog's SDK doesn't see that call, so capture it through PostHog's SDK too:
function track(name, props) {
zaraz.track(name, props)
if (zaraz.consent.get(ANALYTICS) === true) window.posthog?.capture(name, props)
}
track('signup_click', { location: 'header' })
Then a trigger matches it by name and an action on the tool maps the fields (create a trigger):
Trigger Signup click
Rule {{ client.__zarazTrack }} equals signup_click
GA4 action
Action type Event
Firing trigger Signup click
Event name signup_click
Field location = {{ client.location }}
{{ client.__zarazTrack }} is the event name and {{ client.location }} its location property (context). Match the name exactly: a hostname or path rule alone matches every event on the page.
GA4. Pass flat fields. The component adds the ep./epn. prefixes itself, so pre-prefixing risks doubles (request builder). Check two settings: originating-IP forwarding (hideOriginalIP) and ga-audiences. Audiences can make the browser call Google's ad endpoints (listeners).
Google Ads and Bing. With advertising consent they get a page signal, so a later lead can be tied to its ad click through gclid or msclkid. The Ads component can run its conversion linker on a pageview without sending a conversion (Ads component). The Bing component builds UET requests and sends them with client.fetch, from the browser. On my site, that page signal is all they get from browsing.
PostHog. It isn't in the Zaraz catalog (supported tools). I run it as a Custom HTML tool assigned to Analytics, so consent stays in one place. Custom HTML executes in the browser (source), so this is the regular JS SDK. Give the tool an action on the Pageview trigger, with this loader:
<script>
;(() => {
// Zaraz can inject Custom HTML again on client-side pageviews: set up once, then only re-sync
if (window.__posthogConsent) return window.__posthogConsent()
const ANALYTICS = 'your-analytics-purpose-id'
const allowed = () => window.zaraz?.consent?.get(ANALYTICS) === true
// Paste only the first statement of PostHog's web snippet here, the minified
// !function(...) bootstrap. Leave out its <script> tags and its posthog.init() call.
let ready = false
function sync() {
if (!ready) return // loaded() runs the first sync
if (allowed()) {
if (!posthog.has_opted_in_capturing()) posthog.opt_in_capturing()
} else {
posthog.stopSessionRecording()
posthog.opt_out_capturing()
}
}
window.__posthogConsent = sync
posthog.init('<project token>', {
api_host: 'https://ph.example.com', // your first-party reverse proxy
ui_host: 'https://eu.posthog.com',
opt_out_capturing_by_default: true, // nothing is captured until sync() opts in
before_send: (event) => (allowed() ? event : null), // check consent per event; null drops it
loaded: () => {
ready = true
sync() // the choice can change while the SDK downloads
}
})
document.addEventListener('zarazConsentChoicesUpdated', sync)
})()
</script>
There's one posthog.init(), and the SDK starts opted out; loaded runs the first sync, and each change runs it again (config). before_send can edit or drop an event (usage). Withdrawal opts out and stops session recording, if you turned it on. No cookieless_mode here, so a No means no PostHog capture, not cookieless measurement. Pick persistence deliberately: memory isn't the cookieless mode. api_host points at your proxy and ui_host at the PostHog app (proxy); EU Cloud is hosted in Frankfurt (data storage). Zaraz's Ignore SPA option is the other way to stop repeat injection (FAQ).
Shipping Zaraz changes
Zaraz applies dashboard changes in real time by default. Turn on the Preview & Publish workflow and changes stage in a preview instead. zaraz.preview('<debug key>') in the console reloads the page with the debugger and the preview config (Preview & Publish). History lists versions with date, user and description; restore one, then publish it (versions). zaraz.debug('<debug key>') shows events, triggers and component requests, which tells you what Zaraz did, not what the vendor accepted (debug mode).
Cloudflare's official cf CLI, in open beta since 28 September 2026 (launch), wraps the Zaraz API (API):
# Find a command by describing the task
cf cli search "export zaraz configuration"
# "preview" stages changes; "realtime" publishes them at once
cf zaraz workflow get -z example.com
# The export contains secret values: keep it out of git, logs and chat
cf zaraz export get -z example.com > private/zaraz-export.json
# The versions you can restore
cf zaraz history list -z example.com
cf zaraz config update is a PUT of the whole configuration, not a patch, and it has --dry-run. cf zaraz publish create publishes the preview. cf zaraz history update restores a version by ID. Check the workflow first: in realtime, an update is live immediately. The CLI is a wrapper, not a safety net.
On my site, an agent prepares the Zaraz change set, I approve the exact diff before anything is written to Cloudflare, and then it's published.
Testing a change
In the preview, with no consent cookie:
| Answers | Expect |
|---|---|
| None yet | No GA4, Google Ads, Bing or PostHog requests |
| No, No | Still none |
| Yes, No | GA4 and PostHog only |
| No, Yes | Google Ads and Bing only |
| Yes, Yes | All four |
Repeat after a hard reload (the choice holds, no prompt) and a client-side navigation (nothing loads twice). Withdraw Analytics under Privacy preferences and check that GA4 and PostHog stop; grant it again and check that both resume.
The Zaraz debugger shows triggers, actions and component requests. The Network panel shows what the page sends itself, including PostHog's SDK, which the debugger doesn't cover. Each vendor's event view shows what arrived. On Google's requests, read the consent values in gcd, not just its presence.
What's still on you
- The Zaraz loader and Consent API run before anyone answers. They have to, to record the first answer. "No JavaScript before consent" isn't true for this design.
- With a custom prompt, its fairness is on you, not Cloudflare. The EDPB calls visuals that make one choice feel rewarding and the other guilty "emotional steering" (guidelines, written for social media interfaces).
- After a yes, PostHog's SDK runs in the browser and Ads and Bing send requests from it. Masking in session recordings needs more than
maskAllInputs(replay privacy). - Withdrawal stops future capture. It can't recall what was already sent.
- Every new tool needs a purpose, or it skips consent.
- Zaraz includes 1,000,000 events per account a month. Without paid usage, going past that turns Zaraz off until the next billing cycle (pricing).
- If you need IAB TCF, Zaraz has an optional TCF-compliant modal (TCF). A two-question prompt isn't a TCF interface.
- None of this is legal advice.
The maintained article, with the looping recording, every source and the last-checked date, is on argbe.tech.

Top comments (0)