Consent
The gate your consent manager drives, and what each slot sends before an answer, after a refusal and once granted. The plugin stores no consent state and makes no legal decision.
The plugin does not handle consent. It stores no consent state and it does not decide which tracking needs consent. Whether an install needs consent at all, for which slot, and what may be measured before a visitor answers, is the site operator's decision, and your consent manager (c15t, Cookiebot, a banner of your own) owns the banner, the categories, the visitor's stored answer and the record of it.
What the plugin gives that manager is a gate in front of each capture slot: you say on the server which slots wait and what each one does while it has no consent, and the manager answers in the browser. The gate holds what the browser tracker sends; events your server code records are outside it.
Phases and modes
A slot the gate holds spends each page load in one of three phases, which the manager moves it through:
| Phase | Gate status | The slot captures in |
|---|---|---|
| No answer yet | pending | its level for pending |
| Refused | closed | its level for refused |
| Granted | open | full |
What it captures in is its mode, one of four:
| Mode | What the slot sends | What it stores on the device |
|---|---|---|
off | nothing; its vendor script is not loaded | nothing |
cookieless | the vendor's own count without device storage | nothing |
anonymous | native only: hits with no visitor id, country only, no time on page or scroll (below) | nothing |
full | the vendor's normal tracking | whatever the vendor stores (cookies, localStorage) |
capture.withoutConsent sets the level for the two phases without consent, cookieless unless you say otherwise (Measuring without consent); 'off' keeps 1.0.0-beta.14's behaviour, where a held slot sends nothing until it is granted (Strict mode). A slot the gate does not hold runs in full from page load, whatever withoutConsent says.
Which slots wait
capture.gate is required whenever a slot can fill. Without it the boot stops:
analytics: capture.gate is required: 'all' holds every slot until your consent manager calls setGate, 'off' captures without a gate, or list the slots that wait. The plugin does not decide which tracking needs consent.import type { CaptureGate } from '@10x-media/analytics'
export const every: CaptureGate = 'all' // every slot waits
export const none: CaptureGate = 'off' // no slot waits
export const listed: CaptureGate = ['tenant'] // only the slots listed wait
export const perSlot: CaptureGate = ({ kind }) => kind !== 'native' // decide per slot from the adapter filling itPass one of them as analytics({ capture: { gate } }).
The function form receives { slot, adapterId, kind } per request and answers true for a slot that waits. Any answer other than false holds the slot, and so does a function that throws. The answer travels to the browser as each slot's gated flag in the tracker config.
Measuring without consent
capture.withoutConsent says what a held slot does before an answer (pending) and after a refusal (refused):
import type { WithoutConsentOption } from '@10x-media/analytics'
export const counted: WithoutConsentOption = 'cookieless' // the default: both phases
export const strict: WithoutConsentOption = 'off' // send nothing without consent
export const untilRefused: WithoutConsentOption = { refused: 'off' } // a phase left out is 'cookieless'
export const perSlot: WithoutConsentOption = ({ kind, phase }) =>
kind === 'posthog' && phase === 'refused' ? 'off' : 'cookieless'The function receives { slot, adapterId, kind, phase } and answers 'cookieless' or 'off'. It runs on the server, as the tracker config is resolved for a request, so the browser never runs host code: the answer travels as the slot's withoutConsent in the config. A function that throws, or answers anything else, gives off for that slot, and the log says so once per Payload instance.
Each adapter also takes its own withoutConsent, which beats the install's for its slot, in both phases:
import { analytics } from '@10x-media/analytics'
import { native } from '@10x-media/analytics/adapters/native'
export const plugin = analytics({
adapters: [native({ withoutConsent: 'anonymous' })],
capture: { gate: 'all' },
})plausible(), umami(), posthog() and ga4() take 'cookieless' or 'off'; native() takes 'anonymous' as well. A provider document of any vendor type has the same choice in its Without consent field, and left empty it follows the install. Any other value fails the boot, naming the option.
A slot's level in one phase is decided in this order:
- An adapter that declares no way to count without consent is
off. That is a custom adapter's default (adapter contract). - The adapter's own
withoutConsent, from its factory or its provider document. - The install's function, when
capture.withoutConsentis one. - Otherwise
'off'isoff, and'cookieless'(or unset) is the adapter's own default:cookielessfor native, Plausible, Umami and PostHog,offfor GA4.
GA4 therefore sends nothing without consent unless ga4({ withoutConsent: 'cookieless' }), its document's field or a function opts it in: its cookieless pings still reach Google (GA4). On an install whose capture.withoutConsent is 'off', a provider document left empty is off too, and one set to Count without cookies counts.
An install that leaves capture.withoutConsent unset while some held slot can count without consent logs one line per boot:
analytics: capture.withoutConsent is unset, so held slots measure cookielessly before consent and after a refusal. Set it to 'off' to send nothing without consent.Setting the option, to any value, ends it.
Cookieless counting tells visitors apart by a hash that includes the client's IP address, computed by native ingest or by the vendor. Behind a proxy that address has to be read from the right header: a production boot warns when a held slot can count cookielessly while neither trustedProxyHops nor trustedPlatform is set (Trusted proxies).
Legal context. Whether a site may count without consent is not settled by storing nothing on the device. The EDPB Guidelines 2/2023 on the technical scope of Art. 5(3) of the ePrivacy Directive (version 2.0, 7 October 2024), the CNIL's page Cookies : solutions pour les outils de mesure d'audience, and in Germany § 25 TDDDG with the DSK's Orientierungshilfe Digitale Dienste (version 1.2, November 2024) can treat a script that sends information from the visitor's device as access to that device, even when it stores nothing. This page describes what the code sends and stores; it does not say whether any mode is allowed for your site. That choice belongs to the operator, and 'off' is there for an operator who decides against counting without consent.
Strict mode
capture.withoutConsent: 'off' is 1.0.0-beta.14's behaviour, exactly: the tracker config and every snippet are what that release rendered, no request reaches a vendor or native ingest before setGate(true), and none after setGate(false). A held slot at off behaves this way in either phase whatever set its level, GA4's default included.
While such a slot is pending, its events wait in a queue of at most 100 in the page's memory; past that the oldest is dropped. Opening it sends what waited, in order; a refusal drops it. A refusal after a grant stops the plugin's delivery at once, though GA4 and Plausible can still send one last thing of their own (Revoking). Only a native slot queues the engagement messages that carry time on page and scroll, since a vendor slot would drop one on delivery, and the items seen on a page are never queued. A slot that counts without consent queues nothing: it delivers at once, in its mode.
Native: cookieless and anonymous
Native's cookieless level is native's ordinary capture. It never stored anything on the device, so before an answer and after a refusal it sends the same beacons with the same visitor id, a hash of the address, user agent and hostname under the day's salt. A visitor who grants consent halfway through a visit stays one visitor in one visit. Granted, it differs in one thing: the tracker reads the exclusion flag, so a staff browser stops being counted.
native({ withoutConsent: 'anonymous' }) stores less for what it counts without consent. Ingest computes no visitor id for such a hit (no hash, no salt fetched), keeps its country but no region or city, and stores no time on page, scroll depth or items seen: the tracker sends no engagement for an anonymous slot and watches no items, and an anonymous engagement posted anyway is answered 202 and stores nothing. Campaign keys, the referrer's host, device, browser, OS, language, props and item clicks are kept. The cost is in the numbers: anonymous hits count toward pageviews, events, goals, sources, pages and countries, and toward no visitor, visit or bounce rate (Anonymous hits).
Each beacon says which mode the page measured it in: m: 'c' for cookieless, m: 'a' for anonymous, none for full. Ingest stores no more than the mode allows, whatever else the beacon carries, and keeps the mode on the hit. The mode is the page's statement, not proof of consent: anyone can post a beacon to the public endpoint, with or without m.
What each vendor sends and stores
What a slot sends and keeps in the browser in each phase, with its level at cookieless (GA4 opted in). A slot at off sends nothing and stores nothing in that phase, as in Strict mode.
| Slot | No answer | Refused | Granted | After a revoke |
|---|---|---|---|---|
native, cookieless | beacons with m: 'c'; nothing stored | the same | beacons without m, same visitor id | m: 'c' again; nothing stored |
native, anonymous | pageviews and events with m: 'a', no engagement; nothing stored | the same | beacons without m, engagement included | m: 'a' again; nothing stored |
| Plausible | its script, as always; nothing stored | the same | the same script; plausible_ignore cleared if set | unchanged; nothing written |
| Umami | its script, as always; nothing stored | the same | the same script; umami.disabled cleared if set | unchanged; nothing written |
| PostHog | $pageview with $cookieless_mode: true and distinct_id: "$posthog_cookieless"; nothing stored | the same | full capture: persistent id, ph_<token>_posthog cookie, localStorage and sessionStorage, ph_<token>_window_id in sessionStorage, __ph_opt_in_out_<token> in localStorage | cookieless again; left behind: __ph_opt_in_out_<token> (0) in localStorage |
| GA4 | page_view pings with every consent signal denied (gcs=G100); nothing stored | the same | analytics_storage granted (gcs=G101), _ga and _ga_<id without G-> cookies | analytics_storage denied, pings continue; the two cookies expired |
After a revoke, PostHog's __ph_opt_in_out_<token> is the only key any of these leaves. Every cookieless request still carries the visitor's IP address and user agent: to the vendor through the first-party proxy, which forwards User-Agent and the resolved client address in X-Forwarded-For, to Google directly for GA4, and to native ingest, which computes the visitor id from them (Privacy).
Plausible and Umami
Neither stores anything on the device, so one script serves every phase: a cookieless slot loads it before any answer and keeps it after a refusal. At off the script is held until the slot is granted, and a refusal sets the vendor's own switch as in Strict mode (plausible_ignore, umami.disabled). A switch set by an exclusion or a strict refusal on an earlier page is cleared only once the slot runs in full, so until then it keeps the vendor silent in cookieless mode too.
PostHog
A slot that may count without consent starts posthog-js with cookieless_mode: 'on_reject' and, while pending, opt_out_capturing_by_default: true. Before an answer the first $pageview is sent cookieless, and nothing is written to cookies, localStorage or sessionStorage. A refusal before any grant changes nothing: the client keeps counting cookieless and stores nothing. A grant calls opt_in_capturing(), and the next event on the same page is captured in full; a grant given before array.js has loaded makes the first pageview full. A revoke calls opt_out_capturing(), which returns the client to cookieless and deletes ph_<token>_posthog; the sink removes ph_<token>_window_id, which posthog-js keeps past an opt-out.
A slot at off for a phase stops the client there instead: set_config({ cookieless_mode: false }) and opt_out_capturing(), and a refusal before array.js loads stops even the cookieless first pageview. A slot that never counts without consent keeps 1.0.0-beta.14's snippet and switches.
posthog-js keeps a grant in its own storage. On the visitor's next page it starts in full from that storage before your manager answers; a refusal switches it back to cookieless, a pending gate does not. Session replay and surveys start only once the client is opted in. The global client and a tenant's named client switch independently, each on its own slot's gate.
PostHog drops cookieless events at ingestion unless the project's Cookieless tracking setting (Settings, Web analytics) is on; they still show in its Live events. The adapter reads that setting, and the admin says when visits are being dropped (Without consent).
GA4
GA4 is strict by default. Opted in, its snippet sets Google's consent mode defaults before gtag('config'): analytics_storage, ad_storage, ad_user_data and ad_personalization all denied. The tag then sends cookieless pings, page_view included, and stores nothing. A grant updates analytics_storage to granted, and the tag sets _ga and _ga_<measurementId without G->. The three advertising signals stay denied: the plugin grants analytics storage alone, so a site that wants advertising signals updates them from its own bridge with gtag('consent', 'update', ...). A revoke updates analytics_storage to denied, and since gtag.js deletes none of its cookies, the sink expires _ga and _ga_<id> at / on the host and on every parent domain. The pings continue.
A cookieless GA4 snippet reads no storage, so it does not check the exclusion flag the way a strict one does: the tracker reads the flag once the slot runs in full.
The gate in the browser
Each slot's gate is pending, open or closed, held in memory only:
A held slot at off | A held slot counting without consent | A slot that does not wait | |
|---|---|---|---|
| At boot | pending | pending | open |
| Its mode | off | its pending level | full |
| Events | wait in a queue of at most 100 | delivered in that mode | delivered |
| Its vendor script | not loaded | loaded, started in its cookieless variant | loaded |
Opening a slot switches it to full: the vendor's own client is switched up (PostHog opts in, GA4's switch is cleared and, on a slot that counted without consent, analytics storage granted, Plausible's and Umami's keys are cleared) unless the browser is excluded, the slot's snippet is injected if it is not on the page yet, and what waited is sent, in order. Closing a slot moves it to its refused level. A slot that counts after a refusal keeps its client cookieless, or switches it back down from full, and goes on sending; anything that waited is sent cookieless. A slot at off drops what waited and stops delivery; when its vendor's script may be running, because the slot sent on this page or because the site loads the vendor scripts itself (snippets={false}), closing also sets the vendor's own opt-out, the same switches staff exclusion uses (plausible_ignore, umami.disabled, PostHog's opt_out_capturing(), GA4's ga-disable-<measurementId>). A slot that never sent on the page ran nothing, so closing it writes nothing; a slot that does not wait counts as open from boot, so closing it by name always sets the vendor's switch, and it then sends nothing whatever withoutConsent says. A slot can be opened and closed any number of times.
tracker.mode and useAnalytics().mode say what each slot captures in right now, beside gate: { global: 'cookieless', tenant: 'off' }. Both read off for every slot before a tracker has booted and during the server render.
Where both slots are PostHog, each runs its own client (Two PostHog projects on one page), and a slot's gate and exclusion touch only its own: setGate({ tenant: false }) switches posthog.tenant and leaves the platform's client as it was.
A slot the config does not fill, such as the tenant slot of an unscoped install, reads closed and off, and no answer opens it: nothing is captured there. Until the tracker has booted, gate reads what the answers so far say; a false given before the window knows which slots wait reads pending, since it closes a slot that waits and leaves one that does not open.
The gate is not stored. The next page load starts pending again, and your consent manager states the decision on every page load, which it does anyway because it reads its own stored answer there. What a page can leave behind is the vendor's own state: a switch set by a strict refusal, PostHog's opt-in or opt-out key, and whatever a vendor stored while it ran in full (Privacy lists each). 1.0.0-beta.11 kept a decision under the analytics:consent key; the tracker deletes it once a slot runs in full on a page that is counted, and nothing reads it.
Storage before an answer
The tracker reads and writes no browser storage until some slot runs in full. Only then does it read the exclusion flag, at boot for a slot that does not wait, so tracker.excluded and useAnalytics().excluded read false until then, and a staff browser is counted by every slot that counts without consent. A cookieless vendor script stores nothing either (the table above). These touch the device before any answer all the same:
- The exclusion link. A page opened with a valid exclusion link writes the flag at once, whatever the slots are doing, and one opened with
=0reads it and clears it if set. Only staff are handed the signed link. - A GA4 snippet the server rendered in full, for a slot that does not wait, reads the flag itself ahead of its first
gtag()call, the only place GA4 honours its switch. - A grant PostHog kept from an earlier page. posthog-js reads it from its own storage and starts in full, cookie and all, before your manager answers.
- A refusal under
snippets={false}of a slot atoff. The site loads the vendor scripts itself, so closing the slot sets that vendor's opt-out even when the slot never sent: Plausible's and Umami'slocalStoragekey, and PostHog's own stored opt-out when its SDK is on the page.
setGate
setGate(true) // run every slot that waits in full
setGate(false) // move every slot that waits to its refused level
setGate({ tenant: true }) // answer the slots named; a slot left out keeps its gate
setGate({ global: false }) // naming a slot closes it even when it does not waitA boolean answers for the slots capture.gate holds and leaves the others alone, so gate: ['tenant'] with setGate(false) keeps the global slot capturing in full. A record answers for the slots it names, whether they wait or not: it is how you revoke one slot by name, though a slot that does not wait renders its snippet again on the next page load (see revoking). A slot that does not wait is open from the start, and the first true naming it still clears its vendor's switch, as an opening does.
From React, useAnalytics() carries it, next to the current state:
'use client'
import { useAnalytics } from '@10x-media/analytics/react'
const { setGate, gate, mode } = useAnalytics()
gate // { global: 'open', tenant: 'pending' }; a change re-renders the component
mode // { global: 'full', tenant: 'cookieless' }setGate is safe before the tracker exists. A bridge component often answers in an effect that runs before the boot effect of <AnalyticsScripts /> or <AnalyticsProvider>; the answer is kept for the window and the tracker boots with it, so nothing is sent or loaded before it applies. Outside React, the tracker has the same members:
import { initTracker } from '@10x-media/analytics/tracker'
const tracker = await initTracker()
tracker.setGate(true)
tracker.gate // { global: 'open', tenant: 'closed' } on an install without tenancy, whose tenant slot nothing fills
tracker.mode // { global: 'full', tenant: 'off' }Server render
<AnalyticsScripts /> from @10x-media/analytics/site renders what may run before any decision:
- a slot that does not wait renders its vendor snippet with the page;
- a held slot that counts without consent before an answer renders its snippet with the page too, in its cookieless variant, so the first pageview is counted;
- a held slot at
offrenders nothing. Its snippet still reaches the browser, as data inside the tracker config, and the tracker injects it once the slot opens; - the tracker itself boots with every waiting slot
pending, and its loader skips a script already on the page.
A server that already knows the decision passes it, in the same shape as setGate:
<AnalyticsScripts config={tracker} gate={{ tenant: true }} />A slot it opens renders its snippet in full and boots open: for PostHog and GA4 that is the full variant (fullSnippet in the tracker config), so the consenting visitor's first pageview is counted with consent rather than cookielessly. One it closes boots closed, and renders its snippet only when it counts after a refusal. Pass it only from a decision your own code owns. Do not parse a consent manager's cookie to get one: c15t marks its cookie format @internal, so it can change in any release.
<AnalyticsProvider config={tracker} gate={...}> from @10x-media/analytics/react takes the same gate, for a tree that boots the tracker from the provider rather than from <AnalyticsScripts />.
Revoking
A setGate(false) (or { tenant: false }) after a grant moves the slot back to its refused level, on the same page:
- Counting without consent, the slot goes on sending in that mode, and the vendor's client is switched down: native's beacons carry
magain, PostHog returns to cookieless, GA4'sanalytics_storageis denied and its two cookies are expired, and Plausible and Umami carry on as they were, having stored nothing. After that, PostHog's__ph_opt_in_out_<token>is the only key left (the table above). - At
off, what was queued is dropped, nothing more is delivered, and the vendor's own opt-out is set. A script tag already in the page stays there, and the vendor's switch is what silences it. Plausible and Umami read a storedlocalStoragekey and PostHog persists its opt-out, which is why opening a slot clears that switch again. PostHog's opt-out also deletes its ownph_<token>_posthogstorage, because the snippet initialises it withopt_out_persistence_by_default. GA4 honours its switch only when it is set before the page's firstgtag()call, so a tag that already ran is also told through consent mode that all four storage types are denied, and the sink expires_gaand_ga_<id>when this page had granted them. Google may still send cookieless pings until the page is left, and the update applies to every Google tag on that page'sgtag.
At off, two vendors finish what they started before the refusal, by their own behaviour: GA4 holds its events for about 5 seconds before sending them, so an event recorded while consented can still be sent within about 5 seconds after the refusal, and after that gtag sends nothing more and its _ga cookies stay deleted. Plausible's script sends one engagement event at the moment of a refusal, for the page viewed while consented, with the time up to the refusal: it sends it before it checks plausible_ignore, and the plugin stops every engagement after it. Umami sends nothing after a refusal.
What the next page load does depends on whether capture.gate holds the slot:
- A slot that waits starts pending and then takes your manager's
false: atoffits snippet is neither rendered nor injected, and counting without consent it runs in its cookieless variant, so the revoke holds from that load on. - A slot that does not wait renders its snippet in full with every page, before any answer. Plausible, Umami and PostHog read the switch they stored and stay silent. GA4 has no stored switch: its tag runs, reads and writes its cookies and sends its page view on every load, until your bridge closes the slot by name again. A refusal holds for GA4 only when
capture.gateholds its slot.
Every refusal of a GA4 slot expires its _ga and _ga_<id> cookies, on the page's host and each parent domain, including cookies an earlier page's grant left; that write sets no new value and reads nothing. Closing deletes nothing else a vendor stored: whatever a vendor's script wrote while it ran in full stays. Clearing that after a refusal is your consent manager's job, as it is for every script the plugin does not load. Nor does closing delete what native already stored: those hits stay until raw retention deletes them (retention.rawDays, 90 days by default), and the daily totals counted from them stay, as counts with no visitor id in them.
A page restored from the back/forward cache comes back as it was left, its gate open, though the visitor may have refused in another tab since. On such a restore the tracker puts every slot capture.gate holds back to pending and forgets the answers it had: a slot at off queues what the page sends until your consent manager answers again, and one counting without consent returns to its pending level. A vendor already running in full keeps running until the manager answers again, so PostHog does not start a new identity on every Back; a slot that does not wait stays as it was. Answer on pageshow when event.persisted says the page was restored, from the manager's stored answer:
import { initTracker } from '@10x-media/analytics/tracker'
/** Your consent manager's stored answer, read again: another tab may have changed it. */
declare function measurementGranted(): boolean
const tracker = await initTracker()
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
tracker.setGate(measurementGranted())
}
})The recipes below do the same from React.
What the numbers lose
Counting without consent costs accuracy, and each source loses something different:
- PostHog counts a cookieless visitor per day, from a hash PostHog computes on its server, so visitors over a range are a sum of days: a read that finds cookieless pageviews says so (
meta.visitorsAre: 'sumOfDaily', shown as "Visitors, counted per day"). A visitor who grants consent mid-visit is counted twice that day, once cookieless and once under their persistent id. Cookieless events carry no country: PostHog strips the address before it looks one up. posthog-js also drops every event from a browser it takes for a bot (navigator.webdriver, HeadlessChrome), whatever the mode. - Hash collisions. A cookieless visitor is a hash of the address and user agent, so visitors behind one address with the same browser (an office, a school, a mobile carrier's gateway) merge into one, on native and at the vendors alike.
- Native anonymous hits have no visitor. They count as pageviews, events and goals, and toward no visitor, visit, bounce rate or time on page; a read says how many pageviews it holds without a visitor (Anonymous hits).
The analytics view shows what share of a read's pageviews was counted without consent, below the overview cards, for native and PostHog: "38% of pageviews were counted without consent."
What the gate does not cover
The gate holds what the tracker sends from the browser, and nothing else:
- Server code.
trackServerEvent, the serverpurchaseandrefund, and the form-builder goal action record through the native engine directly. No browser gate sees these calls, and the plugin cannot read your consent manager's answer on the server: theiridentity, below, decides whether the visitor behind the request is in the hit. - A slot that does not wait. Its snippet runs in full with the page, before any answer, and counts by that vendor's own rules, whatever
capture.withoutConsentsays. - The ingest endpoint. It is public, so a beacon posted by anything other than the tracker is counted like the tracker's, and the mode a beacon names (
m) is what the page says, not proof of anything. A beacon that names no mode is stored as full.
A server event's identity is 'request' or 'none'. 'request' attributes it to the visitor behind the request (req, or ip and userAgent) as a beacon would: the day's visitor id, and the location, device and language the request carries, stored as a full hit. 'none' records it as no visitor's: no visitor id, and no address, agent, language or location read from the request, not even a country, which still supplies the event's scope, timezone and goals. It is stored as an anonymous hit. The event still counts, as a conversion or a sale, under no visitor of its own.
A call that names no identity gets the install's default:
'none'whilecapture.gateholds a slot the native beacon could fill: the global slot when native fills it, and the tenant slot on any scoped install. A call that passesiporuserAgentanyway has them set aside, and the log says so once.capture.withoutConsentdoes not change this default.'request'otherwise, which is whatgate: 'off'gives.
A sale recorded as no visitor's keeps its place in the visit all the same when the browser recorded it too: the webhook's amount moves onto the browser's record of the order, which carries the visit (recorded in the browser and by the webhook), and that record exists only once the native slot sent it. Where your own code knows the visitor's answer, pass the identity that matches it:
import {
purchase,
type ServerEventInput,
type ServerPurchaseInput,
trackServerEvent,
} from '@10x-media/analytics'
import type { Payload, PayloadRequest } from 'payload'
/** Your own server-side record of the visitor's answer, never a parse of the manager's cookie. */
declare function measuredVisitor(req: PayloadRequest): Promise<boolean>
export const record = async (
payload: Payload,
req: PayloadRequest,
event: ServerEventInput,
sale: ServerPurchaseInput
) => {
const identity = (await measuredVisitor(req)) ? 'request' : 'none'
await trackServerEvent(payload, event, { req, identity })
await purchase(payload, sale, { req, identity })
}The goal action takes the same option, as a value or a function of the submission:
import { trackGoalAction } from '@10x-media/analytics'
import type { PayloadRequest } from 'payload'
declare function measuredVisitor(req: PayloadRequest): Promise<boolean>
export const goalAction = trackGoalAction({
hostname: 'example.com',
identity: async ({ req }) => (req && (await measuredVisitor(req)) ? 'request' : 'none'),
})A queued action runs under the jobs runner's request, never the submitter's, so no visitor's answer can be read there, and 'none' keeps the runner's own address and agent out of the hit. A webhook that passes no req, ip or userAgent is no visitor's under either identity. Which identity a server event gets is the operator's decision.
When the manager loads the vendor scripts itself
Some consent managers inject every vendor SDK themselves, from their own list. snippets={false} keeps the plugin out of that:
<AnalyticsScripts config={tracker} snippets={false} />The plugin then renders no vendor snippet and injects none either, whatever the gate says. Each vendor slot forwards events once that vendor's own global exists (posthog, plausible, umami, gtag), checking for up to 10 seconds per event. A PostHog tenant slot beside a PostHog global slot forwards to the named client posthog.tenant instead, so start the tenant's project as that client, and with capture_pageview: false, since the tracker sends it every pageview itself:
posthog.init('phc_tenant...', { api_host: '/api/_x/p/tenant', capture_pageview: false }, 'tenant')Started under any other name, or not at all, the tenant slot finds no client and sends nothing. Leave the platform's project as the primary client, posthog.init(token, config).
A slot that counts without consent needs its vendor started the way the plugin's own snippet would start it, since the sink only switches a client that can switch. The slot's snippet.scripts in the tracker config already carry that variant; a loader of your own starts PostHog with cookieless_mode: 'on_reject' and opt_out_capturing_by_default: true, and GA4 with gtag('consent', 'default', ...) denying analytics_storage, ad_storage, ad_user_data and ad_personalization before gtag('config'). Started without them, PostHog stores its identity before any answer and an opt-out stops it rather than returning it to cookieless, and GA4 sets its cookies from the first page view.
The gate still applies: a slot at off holds its events while pending and drops them once closed, and since the manager may have loaded the vendor whatever the gate said, closing such a slot sets that vendor's own switch even when it never sent. <AnalyticsProvider snippets={false}> does the same.
Recipe: c15t
This is the setup for a Next.js App Router site running @c15t/nextjs 1.8 (checked against 1.8.5), with analytics filed under c15t's measurement category. Which category analytics belongs to is your decision. Install the 1.x line: @c15t/nextjs 2.x has no @c15t/nextjs/client entry, which this recipe uses, and the recipe does not cover it.
pnpm add @c15t/nextjs@^1.8analytics({
adapters: [native()],
capture: { gate: 'all' },
})With withoutConsent unset, native counts cookielessly from the first page, before the banner is answered and after a refusal, and the boot says so once. Add withoutConsent: 'off' to capture to send nothing until the visitor grants measurement.
The bridge reads c15t's state through useConsentManager() (consents, hasConsented, isLoadingConsentInfo) and answers the gate:
'use client'
import { useAnalytics } from '@10x-media/analytics/react'
import { useConsentManager } from '@c15t/nextjs/headless'
import { useCallback, useEffect, useRef } from 'react'
export function AnalyticsGate() {
const { consents, hasConsented, isLoadingConsentInfo } = useConsentManager()
const { setGate } = useAnalytics()
const opened = useRef(false)
const granted = consents.measurement === true
const decided = hasConsented()
const answer = useCallback(() => {
if (isLoadingConsentInfo) return
if (granted) {
opened.current = true
setGate(true)
} else if (decided || opened.current) {
// A refusal, or a reset that withdrew an answer this page had opened the gate on.
opened.current = false
setGate(false)
}
}, [isLoadingConsentInfo, granted, decided, setGate])
useEffect(() => answer(), [answer])
useEffect(() => {
// A page restored from the back/forward cache holds its gate pending until answered again.
const restored = (event: PageTransitionEvent) => {
if (event.persisted) answer()
}
window.addEventListener('pageshow', restored)
return () => window.removeEventListener('pageshow', restored)
}, [answer])
return null
}The bridge is the same as for 1.0.0-beta.14; what changed is what each answer does. Until the visitor decides, the gate stays pending, and each held slot captures at its pending level: cookieless by default, or nothing under 'off', where what the page sends waits in memory and goes no further than the page. hasConsented() is true once c15t holds a saved answer. A grant runs every held slot in full on the same page. A visitor who refused closes the gate on every page load, which moves each held slot to its refused level, cookieless by default. resetConsents() clears that answer without granting or refusing anything, which is why the bridge remembers that it opened the gate and closes it again: the slots go back from full to their refused level. On a page restored from the back/forward cache the bridge answers again from its own pageshow listener, with the answer c15t holds in that page: nothing in c15t changed, so it reports nothing the bridge could wait for.
Render the bridge under both providers in the site's root layout, with the tracker config resolved on the server. c15t's options (where it keeps answers, which categories it asks for) are its own, and nothing in them changes for the plugin:
import { AnalyticsProvider } from '@10x-media/analytics/react'
import { getTrackerConfig } from '@10x-media/analytics/site'
import { ConsentManagerProvider, CookieBanner } from '@c15t/nextjs'
import config from '@payload-config'
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import { AnalyticsGate } from '@/components/AnalyticsGate'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const payload = await getPayload({ config })
const tracker = await getTrackerConfig(payload, { headers: await headers() })
return (
<html lang="en">
<body>
<ConsentManagerProvider
options={{
mode: 'c15t',
backendURL: '/api/c15t',
consentCategories: ['necessary', 'measurement'],
}}
>
<AnalyticsProvider config={tracker}>
<AnalyticsGate />
{children}
</AnalyticsProvider>
<CookieBanner />
</ConsentManagerProvider>
</body>
</html>
)
}backendURL is where c15t's client sends its own API requests: whether this visitor should see the banner, and each answer, which the backend keeps as a consent record. It names a c15t backend, one you run with @c15t/backend or a hosted c15t instance, here through a path of your own site that you route to it (a Next rewrite or a route handler under /api/c15t). Nothing in the plugin answers there. Without a backend at that address every request fails with a 404: c15t falls back to keeping the answer in the browser and queues each one it could not send in localStorage, to send again on a later page load. The banner and the bridge still work in the page.
To run without a backend, use c15t's offline mode instead: options={{ mode: 'offline', consentCategories: ['necessary', 'measurement'] }}, with no backendURL. c15t then sends no request at all, keeps the answer in the browser alone (a cookie and localStorage), shows the banner until there is one, and keeps no consent record anywhere else, which may matter if you need to prove a consent later. The bridge above works the same in either mode.
Three more things:
- One loader per vendor. When GA4 or PostHog fills a plugin slot, drop that vendor from c15t's
scriptsoption (passed throughClientSideOptionsProviderfrom@c15t/nextjs/clientin the App Router); otherwise the page runs two copies of the tag and counts everything twice (Running beside an existing setup has the migration order). Pixels and every vendor the plugin does not capture for stay with c15t. - No
gateprop from the cookie. Do not pass the server render agateread from the c15t cookie; let the bridge answer in the browser. - Server code is not gated. The bridge answers for the browser alone. With
gate: 'all',trackServerEvent,purchaseand the goal action record their events as no visitor's unless your code passesidentity: 'request'where it knows the visitor consented (what the gate does not cover).
Recipe: any consent manager
Every consent manager offers a way to read the current decision and to hear when it changes. Map that to setGate in one client component, rendered under <AnalyticsProvider> or anywhere on a page that renders <AnalyticsScripts />:
'use client'
import { useAnalytics } from '@10x-media/analytics/react'
import { useEffect } from 'react'
/** Your manager's API: replace these two with its own read and change event. */
declare function readDecision(): 'granted' | 'denied' | undefined
declare function onDecisionChange(listener: () => void): () => void
export function AnalyticsGate() {
const { setGate } = useAnalytics()
useEffect(() => {
let opened = false
const answer = () => {
const decision = readDecision()
if (decision === 'granted') {
opened = true
setGate(true)
} else if (decision === 'denied' || opened) {
// A refusal, or a withdrawn answer that had opened the gate on this page.
opened = false
setGate(false)
}
}
// A page restored from the back/forward cache holds its gate pending until answered again.
const restored = (event: PageTransitionEvent) => {
if (event.persisted) answer()
}
answer()
window.addEventListener('pageshow', restored)
const stop = onDecisionChange(answer)
return () => {
window.removeEventListener('pageshow', restored)
stop()
}
}, [setGate])
return null
}The bridge has not changed since 1.0.0-beta.14. While the manager has no answer, each held slot captures at its pending level; 'granted' runs them in full, and 'denied' moves them to their refused level, which is cookieless unless capture.withoutConsent says 'off'. A manager that can withdraw an answer without refusing (a reset, a consent record that expired) reads as undecided again; the bridge then closes a gate it opened, moving the slots to their refused level rather than leaving them in full until the next page load. On a page restored from the back/forward cache it reads the manager's answer again, which picks up a refusal given in another tab when the manager reads its stored answer there. Answer per slot when your manager has more than one category for them, for example setGate({ global: measurement, tenant: marketing }). Without React, call tracker.setGate() on the tracker initTracker() resolved, from the same two hooks.
A custom script loader
<AnalyticsScripts /> is a server component and takes no function. A site that loads scripts through its own loader passes loadScript to <AnalyticsProvider>, from a client component of its own, since a function cannot cross from a server component either:
'use client'
import { AnalyticsProvider, type LoadScript, type TrackerConfig } from '@10x-media/analytics/react'
/** Your framework's loader: resolves once the script has run. */
declare const loadScript: LoadScript
export function Analytics({ config, children }: { config: TrackerConfig; children: React.ReactNode }) {
return (
<AnalyticsProvider config={config} loadScript={loadScript}>
{children}
</AnalyticsProvider>
)
}Render that instead of <AnalyticsScripts />: whichever component boots the tracker first owns its options.