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 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 geo key, 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 geo is set, the init() snapshot returns geoPending: true until the verdict resolves. Branch on that rather than reading hasConsentDecision() immediately — it's still false while 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