Skip to main content

Menu

Choose a theme and configure high-contrast mode. Preferences are saved in your browser only.

User Preferences

Theme

Pick a palette or follow your system preference.

High Contrast

Sharper text and borders. System follows your OS setting.

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 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'
    }
  }
};

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 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