Geo Targeting
Show the right consent experience per jurisdiction with Zest geo gating
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 itOr 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'
}
}
};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
