DEV Community

Aleksandr Argunov
Aleksandr Argunov

Posted on Originally published at argbe.tech

Zaraz consent with your own prompt: the Consent API, Google Consent Mode and PostHog via Custom HTML

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.

The consent prompt on a phone, docked over the privacy policy page. A small square bot sits beside the first question: Can I see how you use the site? Yes is pressed and the bot smiles. The second question follows: Can ad platforms count visits that came from an ad? No is pressed. The card says: Saved. Analytics is on; advertising measurement stays off. The bot smiles, then winks and sways goodbye, and the card slides away.

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 true or false. The cookie name is configurable; Cloudflare's example uses cf_consent.
  • By default, Zaraz shows its modal to anyone who hasn't chosen. consent.hideModal: true in the root consent object 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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}=`))
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    })
)
Enter fullscreen mode Exit fullscreen mode

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' })
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

{{ 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)