Geo Targeting
Since v2.4.0.
Overview
By default Zest shows the consent banner to everyone. Opt into geo gating and Zest resolves the visitor’s location and decides which consent experience to present:
window.ZestConfig = { geo: true }; // that's it
Or via data attribute:
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest" data-geo="on"></script>
Default behavior once enabled, matching the legal model:
| Jurisdiction | Action | What the visitor sees |
|---|---|---|
| GDPR / EEA / UK | consent |
Opt-in — full banner, tracking blocked until they choose |
| US state-privacy (CCPA, CPRA, VCDPA…) | notice |
Opt-out — tracking runs, a small “Do Not Sell or Share My Personal Information” link |
| Everywhere else | allow |
Nothing — tracking allowed, no UI |
No behavioral change unless you opt in. With no
geokey, Zest shows the banner to everyone exactly as before.
Choosing the Verdict Source
geo: true is shorthand for { provider: 'gateway' }, which uses the hosted zest-geo gateway at https://geo.cookiezest.com/privacy — a Cloudflare Worker that reads the edge geo of the request and returns the applicable privacy regimes. It stores nothing, logs nothing, and the /privacy endpoint carries no IP / city / coordinates. Zero infrastructure on your side.
The verdict source is pluggable — pick ONE:
window.ZestConfig = {
geo: {
provider: 'gateway', // hosted zest-geo (default)
// endpoint: 'https://geo.example.com/privacy', // your self-hosted zest-geo
// resolver: async () => ({ isGDPR, isUSPrivacy, isCCPA, isEU, isEEA, regulations }),
// Optional — map the verdict to an action (this is the default):
decide: (geo) =>
geo.isGDPR ? 'consent' : geo.isUSPrivacy ? 'notice' : 'allow',
timeout: 1500, // ms before giving up
fallback: 'consent' // action if resolution fails/times out (fail-closed)
}
};
The resolver option is the recommended path if your CDN already knows the country — read its geo header (CF-IPCountry, x-vercel-ip-country, etc.) and return the verdict yourself, no extra request. Whatever the source, it must return the gateway’s shape:
{ isEU, isEEA, isGDPR, isCCPA, isUSPrivacy: boolean, regulations: string[] }
Actions
decide() must return one of four actions:
| Action | Interceptors | UI |
|---|---|---|
'consent' |
stay blocked until decision | full banner / modal |
'notice' |
allow + replay queue (opt-out) | “Do Not Sell” link |
'allow' |
allow + replay queue | nothing |
'block' |
stay blocked | nothing (fail-closed) |
Anything outside this set is clamped to fallback.
The “Do Not Sell” Notice
For the 'notice' action the full build shows a minimal, dismissible Shadow-DOM link. It is not a consent gate — tracking already runs (opt-out model); clicking the link flips to a full reject. Customize the text via labels.notice:
window.ZestConfig = {
geo: true,
labels: {
notice: {
title: 'Your Privacy Choices',
optOut: 'Do Not Sell or Share My Personal Information',
dismiss: 'Dismiss'
}
}
};
DNT, GPC and Geo
Two things can act before a decision exists: the visitor’s browser privacy signal (DNT or GPC) and the geo verdict. Zest checks them in a fixed order. The browser signal is read first, synchronously during init. Geo resolves second, asynchronously. A signal-triggered reject counts as a decision, so for that visitor the geo lookup never even runs.
With the defaults (respectDNT: true, dntBehavior: 'reject'), a visitor sending GPC from the US gets auto-rejected even if your geo config says “allow tracking outside the EU”. The signal wins. No banner, no notice, everything non-essential stays blocked. The same applies to any opt-out or no-law region.
The signal only loses its lead if you stop enforcing it:
| Setting | GPC/DNT visitor in an opt-out or no-law region |
|---|---|
dntBehavior: 'reject' (default) |
Auto-rejected before geo resolves. Nothing shows, nothing runs. |
dntBehavior: 'preselect' |
The signal records no decision, so geo runs: tracking is granted, with the “Do Not Sell” notice (US) or no UI at all (rest of world). |
dntBehavior: 'ignore' or respectDNT: false |
Signal fully ignored, only the geo outcome applies. |
So “banner in the EU, allow everywhere else” combined with anything other than reject is the one setup where a GPC visitor ends up granted. Under defaults it cannot happen.
One more nuance: an existing choice beats both. The auto-reject only fires when no decision exists yet, so a returning visitor keeps whatever they picked earlier, and geo never re-runs once a decision is recorded.
How It Works (and the One Caveat)
Interceptors install synchronously on script eval, so trackers stay blocked no matter what. Geo resolves asynchronously — Zest holds the UI until the verdict lands, then mounts the right experience. On timeout or error it fails closed to fallback (default 'consent').
Trade-off: in allow / notice regions, trackers are held for the brief gateway round-trip (~tens of ms) before being released. That’s correct fail-closed behavior — nothing leaks while the verdict is in flight.
Security
Geo verdicts are untrusted input (they cross the network or come from consumer code), so every field is sanitized before any decision — booleans forced, regulations[] filtered to short strings and capped, unknown keys dropped (prototype-pollution safe). geo.endpoint is validated to http/https only.
Reacting to the Verdict
Once resolution completes, the onGeo(action, verdict) callback and the zest:geo DOM event fire:
window.ZestConfig = {
geo: true,
callbacks: {
onGeo: (action, verdict) => {
console.log('Geo action:', action); // 'consent' | 'notice' | 'allow' | 'block'
console.log('Regulations:', verdict?.regulations);
}
}
};
document.addEventListener('zest:geo', (e) => {
console.log(e.detail.action, e.detail.verdict);
});
Headless
Geo works in headless too — the resolver lives in the shared core; only the visual notice is full-build-only. Headless renders no UI, so the result reaches you via the onGeo callback, the zest:geo event, or await Zest.resolveGeo(). Tracking is already accepted for notice / allow by the time it fires:
import Zest from '@freshjuice/zest/headless';
Zest.init({
geo: true,
callbacks: {
onGeo: (action, verdict) => {
if (action === 'consent') myBanner.show(); // GDPR — opt-in
if (action === 'notice') myDoNotSellLink.show(); // US — opt-out
// 'allow' / 'block' — render nothing
}
}
});
// …or await it directly:
const { action, verdict } = await Zest.resolveGeo();
When
geois set, theinit()snapshot returnsgeoPending: trueuntil the verdict resolves. Branch on that rather than readinghasConsentDecision()immediately — it’s stillfalsewhile resolution is in flight.
Framework Plugins
The Astro and Eleventy plugins serialize config to an inline window.ZestConfig, so the serializable geo forms (geo: true, provider, endpoint, timeout, fallback) work; the function forms (resolver / decide) do not survive serialization — use them in client-side JS instead.
Next Steps
- Configuration Options - All other config options
- Events & Callbacks - The
zest:geoevent in context - API Reference -
Zest.resolveGeo()and friends