This is the abridged developer documentation for ConsentLayer
# Introduction
> ConsentLayer is a consent-first analytics library for React, Next.js, Vue, Nuxt, Svelte, SvelteKit, Solid, SolidStart, Angular and the server.
This page describes what ConsentLayer does, how the documentation is organized,
what runs in the browser and on the server, and what each entry point adds to
your bundle.
:::caution[Not legal advice]
ConsentLayer has not been legally reviewed. Whether your implementation
complies with the law where you and your visitors are is your responsibility;
see the [legal notice](/guides/legal/).
:::
## What is ConsentLayer?
ConsentLayer is an analytics library that routes your events to GA4, Meta
Pixel, PostHog, Sentry and the other supported providers, and decides from the
visitor's consent which of those providers may load and receive data. You
create one instance with `createAnalytics()`, track events through it once, and
each provider translates them into its own format.
Every provider declares a `requiredConsent`: one of the built-in categories
`preferences`, `analytics` and `marketing`, a
[custom category](/guides/consent-ui/#custom-categories) you declare, `'none'`,
or a function of the current consent. ConsentLayer runs a provider's `load`
hook, which injects the vendor's script or imports its SDK, only once that
requirement is met. Page views, events, identity and errors are delivered only
while it is met, and withdrawing consent runs the provider's `teardown` hook.
The exceptions are explicit: providers with `'none'`, such as Sentry error
reporting and cookieless PostHog, run without consent, and GA4 and Meta load
before consent only when you set `loadBeforeConsent: true` to rely on the
vendor's own consent mode. The [Providers](/guides/providers/) guide lists the
category of each provider.
```ts
import { createAnalytics } from 'consentlayer'
import { ga4 } from 'consentlayer/providers/ga4'
import { meta } from 'consentlayer/providers/meta'
export const analytics = createAnalytics({
providers: [ga4({ measurementId: 'G-XXXXXXX' }), meta({ pixelId: '123456789012345' })],
})
analytics.track('purchase', { transaction_id: 'o1', value: 30, currency: 'EUR' })
```
Creating the instance loads nothing. Providers start when `analytics.boot()`
runs, which `` calls for you. Calls made before then are held,
up to 100, and delivered when boot completes to the providers whose requirement
is met at that moment.
The npm package ships no consent banner. `useConsentController()` exposes the
banner's state and actions, and the [Consent UI](/guides/consent-ui/) guide
provides a recipe to copy and restyle, so the markup, wording and translations
stay in your codebase. On a site without a build step, the
[script tag](/start/script-tag/) loads ConsentLayer and a ready-made banner
from a CDN.
Where consent is stored and how it is announced
The visitor's choice is a `ConsentState` object with one boolean per category.
It is stored in `localStorage` under `consentlayer_consent`, with the date it
was made and, if you set one, your policy version, so ConsentLayer can
[ask again](/guides/consent-ui/#asking-again) later. On every change,
ConsentLayer assigns it to `window.CONSENTLAYER_CONSENT` and dispatches a
`consentlayer:consent` DOM event whose `detail` is the new state, so scripts
outside ConsentLayer can react to it. All three names derive from
[`names.prefix`](/guides/names/).
On startup, `analytics.consent.bootstrap()` reads the stored choice. If there
is none, it asks each consent adapter in order, such as the Shopify Customer
Privacy adapter, and keeps the first non-null answer. If neither yields a
choice, consent stays `null`, which satisfies no requirement other than
`'none'`, and `` shows the banner. It shows the banner too
when the stored choice is stale: made under an earlier major policy version or
longer ago than `maxAgeDays`. The full sequence is described in
[Consent](/internals/consent/).
## How to use the docs
The docs are organized into the following sections:
- **Getting started**: Installation, a step-by-step setup for React, Next.js,
Vue, Nuxt, Svelte, SvelteKit, Solid, SolidStart, Angular and plain JavaScript, and an interactive [Playground](/playground/) that logs every
provider hook as you change consent.
- **Guides**: Task-oriented pages for tracking [events and identity](/guides/events/),
building the [consent UI](/guides/consent-ui/), configuring
[providers](/guides/providers/), [server-side tracking](/guides/server-side-tracking/),
[consent records](/guides/consent-records/) and [names](/guides/names/).
- **API reference**: Every export, option, method, hook and route handler, with
types and defaults.
- **How it works**: The internals: the [instance lifecycle](/internals/lifecycle/),
the [consent engine](/internals/consent/), the [providers](/internals/providers/),
the [server handlers](/internals/server/) and the [plugin contract](/internals/plugins/).
- **Help**: [Migrating to v1](/guides/migrating-to-v1/) and
[Troubleshooting](/guides/troubleshooting/).
Use the sidebar to navigate between sections, or search with Ctrl+K
or Cmd+K.
For AI assistants and coding agents, the docs are also published as plain
Markdown: [`/llms.txt`](/llms.txt) lists them,
[`/llms-full.txt`](/llms-full.txt) holds every page, and
[`/llms-small.txt`](/llms-small.txt) leaves out the internals, the migration
guide and the changelog.
## Browser and server
ConsentLayer runs on both sides of your application:
- **In the browser**, `createAnalytics()` holds the consent state and the
providers. It loads each provider when its requirement is met, translates
your events into each provider's format, and routes errors from
`captureError` and the window's `error` and `unhandledrejection` events to the
providers that report errors, Sentry and PostHog.
- **On the server**, `consentlayer/server` provides web-standard route
handlers of the form `(request: Request) => Promise`.
`createAnalyticsHandler` receives the events the browser forwards when you set
`server.endpoint` and sends them to the Meta Conversions API, the TikTok
Events API or a server-side GTM container. `sendServerEvent` sends events
that never pass through the browser, such as a confirmed payment from a
webhook, to those destinations and to the GA4 Measurement Protocol.
`createConsentHandler` appends each consent choice to your database and sets
a signed cookie that proves it.
The browser forwards an event to the server only while `analytics` or
`marketing` is granted, and the server sends it only to the destinations whose
own category is granted in the event's consent: `marketing` for Meta and
TikTok, and `analytics` for server-side GTM and GA4. Each event carries one ID, the `eventId` you pass to
`track()` or a generated UUID, which is used both in the browser and on the
server, so Meta can deduplicate an event it receives from both.
## Bundle size
Each entry point is a separate module, the package is marked free of side
effects so bundlers drop the exports you don't import, and provider SDKs,
Sentry Session Replay and Sentry tracing are loaded with dynamic `import()`
only when they are first needed. These are the sizes of the current release,
minified and brotli-compressed, each including the shared code it imports, so
they don't add up exactly. Vendor SDKs, whether installed from npm or loaded
from the vendor's CDN, are not included.
| Entry point | Size |
| -------------------------------------------------- | ------- |
| `consentlayer`, everything | 7.0 kB |
| `consentlayer`, only `createAnalytics` | 5.6 kB |
| `consentlayer/auto-tracking` | 0.9 kB |
| `consentlayer/react` | 2.1 kB |
| `consentlayer/next` | 1.6 kB |
| `consentlayer/vue` | 2.2 kB |
| `consentlayer/svelte` | 1.9 kB |
| `consentlayer/sveltekit` | 2.0 kB |
| `consentlayer/solid` | 2.1 kB |
| `consentlayer/solidstart` | 2.2 kB |
| `consentlayer/angular` | 2.2 kB |
| `consentlayer/providers/ga4` | 1.9 kB |
| `consentlayer/providers/gtm` | 1.8 kB |
| `consentlayer/providers/meta` | 1.8 kB |
| `consentlayer/providers/tiktok` | 1.8 kB |
| `consentlayer/providers/contentsquare` | 1.1 kB |
| `consentlayer/providers/posthog` | 1.0 kB |
| `consentlayer/providers/sentry` | 0.9 kB |
| `consentlayer/server` | 2.4 kB |
| `consentlayer/server/meta` | 1.3 kB |
| `consentlayer/server/tiktok` | 1.2 kB |
| `consentlayer/server/sgtm` | 1.0 kB |
| Script tag, `consentlayer.global.js` | 14.0 kB |
| Script tag banner, `consentlayer-banner.global.js` | 1.7 kB |
The Svelte figures count the `MetaPixelNoscript` and `GtmNoscript` components
as source, which your Svelte compiler builds. `consentlayer/nuxt` runs at build
time, and adds `consentlayer/vue` to your application. To keep your own bundle
small, see [Reduce bundle size](/guides/bundle-size/).
# Consent adapters
> Adapters connect ConsentLayer to other consent systems: IAB TCF, Shopify Customer Privacy and Google Consent Mode.
A consent adapter keeps ConsentLayer's consent in sync with another consent
system: it reads the visitor's choice from a TCF CMP, syncs it with Shopify, or
sends it to Google tags as Consent Mode signals.
Adapters are passed to `createAnalytics({ consent: { adapters } })`, not to
`providers`:
```ts
import { googleConsentMode } from 'consentlayer/adapters/google-consent-mode'
import { tcf } from 'consentlayer/adapters/tcf'
createAnalytics({
providers,
consent: { adapters: [tcf(), googleConsentMode()] },
})
```
## Available adapters
Each adapter is a named export of its own entry point,
`consentlayer/adapters/`.
| Adapter | Factory | Reads the choice | Writes the choice |
| ------------------------------------------------------------ | -------------------------- | ---------------- | --------------------------------------- |
| [IAB TCF](/guides/adapters/tcf/) | `tcf()` | From the TCF CMP | No |
| [Shopify Customer Privacy](/guides/adapters/shopify/) | `shopifyCustomerPrivacy()` | From Shopify | To Shopify |
| [Google Consent Mode](/guides/adapters/google-consent-mode/) | `googleConsentMode()` | No | To Google tags, as Consent Mode signals |
## Order
A choice stored by ConsentLayer wins. Without one, the adapters that read a
choice are asked in the order you pass them, and the first that has one
provides it. Later changes made in an adapter's system flow back into
ConsentLayer, and every choice made through ConsentLayer is written to the
adapters that write. [Consent](/internals/consent/#adapters) describes each
step, and how to write your own adapter.
# Google Consent Mode
> googleConsentMode() sends the visitor's choice to Google tags as Consent Mode v2 signals, with region defaults and Google's other settings.
`googleConsentMode()` sends the visitor's choice to Google tags as
[Consent Mode v2](https://developers.google.com/tag-platform/security/guides/consent)
signals. GA4, Google Ads, Floodlight and the tags Google Tag Manager loads all
read them, so the adapter works with or without [`ga4()`](/guides/providers/ga4/).
```ts
import { googleConsentMode } from 'consentlayer/adapters/google-consent-mode'
createAnalytics({
providers,
consent: {
adapters: [
googleConsentMode({
regions: [{ region: ['US'], analytics: true, marketing: true }],
waitForUpdate: 500,
}),
],
},
})
```
`ga4()` sets the same defaults and updates on its own, so you need the adapter
only for the settings below or for Google tags without `ga4()`. When the
adapter is there, `ga4()` leaves Consent Mode to it, so the defaults are sent
once.
## Options
| Option | Type | Default | Description |
| ------------------ | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `regions` | `GoogleConsentModeRegion[]` | `[]` | Categories that count as granted before a choice in the listed regions. See [Region defaults](#region-defaults). |
| `waitForUpdate` | `number` | None | Milliseconds Google tags wait for a consent update before sending, as `wait_for_update` in each default. A whole number, `0` or more. |
| `urlPassthrough` | `boolean` | `false` | `gtag('set', 'url_passthrough', true)`: passes ad click IDs through links while `ad_storage` is denied. |
| `adsDataRedaction` | `boolean` | `false` | `gtag('set', 'ads_data_redaction', true)`: redacts ad click data while `ad_storage` is denied. |
## Signals
When `createAnalytics()` creates the instance in the browser, before any
network request and whatever the [`boot`](/start/react/#boot-timing) timing,
the adapter creates `window.dataLayer` and `window.gtag` and sets the defaults:
`ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage` and
`functionality_storage` denied, `security_storage` granted. Once the visitor
has a choice, from the banner, storage, another tab or another adapter, each
change sends `gtag('consent', 'update')` with this mapping:
| Consent Mode signal | Granted when |
| -------------------------------------------------- | ------------------------ |
| `analytics_storage` | `analytics` is granted |
| `ad_storage`, `ad_user_data`, `ad_personalization` | `marketing` is granted |
| `functionality_storage` | `preferences` is granted |
| `security_storage` | Always |
A Google tag that runs before your bundle, such as a Google Tag Manager snippet
at the top of ``, still runs before the defaults exist. Load such tags
after your bundle, or set the defaults yourself in an inline script before
them.
## Region defaults
Each entry in `regions` lists ISO 3166-2 codes, a country such as `'US'` or a
subdivision such as `'US-CA'`, and the categories granted there before a
choice. Every category it leaves out is denied, and visitors outside every
listed region keep the denied defaults. Google picks the entry by the
visitor's location, and the most specific region wins:
```ts
regions: [
{ region: ['US'], analytics: true, marketing: true },
{ region: ['US-CA'], analytics: true },
]
```
Here a visitor in California starts with analytics granted and marketing
denied, the rest of the United States with both granted, and everyone else
with everything denied. A region default affects Google tags only:
ConsentLayer's consent stays `null` until the visitor chooses, so the banner is
shown and other providers wait. Once the visitor chooses, their choice
replaces the default.
`ga4()` in its default basic mode configures the tag only after consent, so
GA4 never acts on a region default. Use
[`consentMode: 'advanced'`](/guides/providers/ga4/#advanced-mode) for GA4 to
send hits under one; other Google tags on the page act on it either way.
A code that is not two uppercase letters, optionally followed by `-` and up to
three letters or digits, throws, and so does a `waitForUpdate` that is not a
whole number of `0` or more.
# Shopify Customer Privacy
> shopifyCustomerPrivacy() keeps ConsentLayer and Shopify’s Customer Privacy API in agreement.
`shopifyCustomerPrivacy()` keeps the instance and Shopify's
Customer Privacy API in agreement. When no choice is stored locally, it reads
the visitor's existing choice from `Shopify.customerPrivacy.currentVisitorConsent()`
at bootstrap. Every choice made through ConsentLayer is written to Shopify with
`setTrackingConsent()`, and Shopify's `visitorConsentCollected` DOM event is
applied back to the instance. The optional `sale_of_data` flag is passed
through in both directions.
```ts
import { shopifyCustomerPrivacy } from 'consentlayer/adapters/shopify'
createAnalytics({
providers,
consent: {
adapters: [
shopifyCustomerPrivacy({
headlessStorefront: { storefrontAccessToken: process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN },
}),
],
},
})
```
On a Shopify-hosted theme the token is unset and `headlessStorefront` is
ignored; on a headless storefront it is set and the headless options apply.
## Options
| Option | Type | Default | Description |
| -------------------- | ------------------------------------------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loadFeature` | `boolean` | `true` | Before reading or writing consent, loads Shopify's Customer Privacy script on a headless storefront (when `storefrontAccessToken` is set and `Shopify.customerPrivacy` is missing), then calls `Shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }])` when available. `false` skips both. |
| `headlessStorefront` | `{ checkoutRootDomain?, storefrontRootDomain?, storefrontAccessToken? }` | None | Required off Shopify-hosted themes. Applies only once `storefrontAccessToken` is set, so you can pass the environment variable directly; then passed to `setTrackingConsent` with `headlessStorefront: true`. |
| `canBootstrap` | `() => boolean` | None | Returning `false` skips reading Shopify's existing choice at bootstrap. |
| `document` | `Document` | The global `document` | The document to listen on. |
| `window` | `Window` | The global `window` | The window that holds `Shopify`. |
Shopify reports consent as strings. `granted`, `allow`, `accepted`, `yes` and
`true` (case-insensitive) map to `true`; any other value maps to `false`. If
Shopify reports no value for any category, the visitor is treated as not
having chosen yet.
On a headless storefront the adapter loads Shopify's script from
`cdn.shopify.com` itself. Its URL is also returned by
`getShopifyCustomerPrivacyScriptUrl()`, exported from the same entry point,
for a Content Security Policy or a preload hint.
# IAB TCF
> tcf() reads the visitor's choice from an IAB TCF v2 CMP already on the page.
`tcf()` reads the visitor's choice from a Consent Management
Platform (CMP) that implements the
[IAB Transparency and Consent Framework](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework)
v2 and is already on the page. It talks to the CMP through the standard
`window.__tcfapi` function, maps the consent the visitor gave to TCF purposes
onto ConsentLayer's categories, and follows every change the CMP reports.
```ts
import { tcf } from 'consentlayer/adapters/tcf'
createAnalytics({
providers,
consent: { adapters: [tcf()] },
})
```
The CMP is required. ConsentLayer does not show a TCF consent dialog, does not
write a TC string and is not a registered CMP: the adapter only reads what the
CMP decided.
## The CMP owns the choice
The adapter is read-only. The TCF CMP API has no command for writing consent
back, so the adapter has no `apply` method, and a choice made through
ConsentLayer, such as `analytics.consent.update()` or a settings dialog built
on [`useConsentController()`](/guides/consent-ui/), never reaches the CMP. The
CMP's next update overwrites it.
Visitors change their choice in the CMP's own interface. Link to the CMP's
settings, not to ConsentLayer's, and don't render ConsentLayer's banner where
the CMP shows its own. While the CMP reports that GDPR applies, the adapter
always provides a choice, so `bannerVisible` stays `false` there.
## Purposes and categories
A category is granted when the visitor consented to every purpose mapped to
it, that is when `tcData.purpose.consents[id]` is `true` for each `id`.
| Category | TCF purposes |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preferences` | 1 (Store and/or access information on a device) |
| `analytics` | 1, 8 (Measure content performance) |
| `marketing` | 1, 2 (Use limited data to select advertising), 3 (Create profiles for personalised advertising), 4 (Use profiles to select personalised advertising), 7 (Measure advertising performance) |
Only consent counts. A purpose the CMP reports under legitimate interest, in
`tcData.purpose.legitimateInterests`, does not grant a category.
The `purposes` option changes the mapping. It is merged over the defaults one
category at a time, so you name only the categories you change. A
[custom category](/guides/consent-ui/#custom-categories) is granted only if
`purposes` maps it; a category mapped to an empty list is never granted.
```ts
tcf({
purposes: { analytics: [1, 8, 9], functional: [1, 5, 6] },
})
```
## Options
| Option | Type | Default | Description |
| ---------- | -------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------ |
| `purposes` | `Partial>` | The table above | TCF purpose IDs per category, merged over the defaults. |
| `timeout` | `number` | `5000` | Milliseconds bootstrap waits for the CMP before it fails. A negative or non-finite value throws. |
| `window` | `Window` | The global `window` | The window that holds `__tcfapi`. |
## When there is no TCF choice
The adapter returns `null` from bootstrap, which leaves the visitor to
ConsentLayer's own banner, in two cases:
- **No CMP.** `window.__tcfapi` is not defined when the instance bootstraps.
- **GDPR does not apply.** The CMP reports `gdprApplies: false`, typically for
a visitor outside the EU. Updates with `gdprApplies` other than `true` are
ignored too, so a choice made in ConsentLayer's banner is kept.
In both cases ConsentLayer falls back to the stored choice. To serve both
groups, render ConsentLayer's banner as usual: it shows only when the adapter
returned `null` and no choice is stored.
While the CMP shows its interface
On a first visit the CMP reports `eventStatus: 'cmpuishown'` before the
visitor has chosen. Its TC data then has no purpose consents, so the adapter
returns every category denied, marked as provisional. ConsentLayer applies it
but does not store it: `ready` becomes `true`, `bannerVisible` stays `false`, providers that need no
consent boot, and the others wait. When the visitor confirms in the CMP, it
reports `eventStatus: 'useractioncomplete'`, and the adapter's subscription
applies and stores the new consent.
A visitor who leaves without choosing has nothing stored, so the next visit
starts the same way. The adapter is authoritative, so on every visit
ConsentLayer asks the CMP before it reads the stored choice, nothing that
needs consent boots until the CMP answers, and the CMP's answer replaces the
stored choice.
How the adapter talks to the CMP
Both bootstrap and the subscription call
`__tcfapi('addEventListener', 2, callback)`. Bootstrap takes the first
callback and then removes its listener with
`__tcfapi('removeEventListener', 2, callback, listenerId)`. The subscription
maps every callback until the instance is destroyed, then removes its
listener the same way. A callback with `success: false` counts as no choice.
If the page has the CMP's `__tcfapi` stub but the CMP itself never loads, for
example because a content blocker stops it, the stub queues the call and
bootstrap waits `timeout` milliseconds. Then it fails with
`The TCF CMP did not answer within ms`, reported to `onError` with
scope `consent-adapter`, and ConsentLayer falls back to the stored choice, or
to its own banner when nothing is stored.
## Limitations
- **Choices made in ConsentLayer are overwritten.** The CMP's next update
replaces a choice made through ConsentLayer, as described in
[The CMP owns the choice](#the-cmp-owns-the-choice).
- **A CMP that answers late meets ConsentLayer's banner.** With the CMP's
`__tcfapi` stub on the page but the CMP slower than `timeout`, ConsentLayer
falls back to the stored choice or shows its banner, and the CMP may then
show its own on top. The subscription still applies the CMP's choice once
it answers. Raise `timeout` if your CMP loads slowly.
- **Tested against the specification, not against named CMPs.** The adapter
is tested with a simulated `__tcfapi` that follows the IAB CMP API v2, not
with Usercentrics, OneTrust, Didomi or another CMP.
# Auto-tracking
> Send page session, click, scroll depth and time-on-page events automatically once analytics consent is granted.
`autoTracking()` from `consentlayer/auto-tracking` sends page session, click,
scroll depth and time-on-page events without a `track` call for each. It is a
separate entry point, so applications that don't use it don't download it.
```ts
import { createAnalytics } from 'consentlayer'
import { autoTracking } from 'consentlayer/auto-tracking'
export const analytics = createAnalytics({
providers: [/* ... */],
autoTracking: autoTracking(),
})
```
The [script tag](/start/script-tag/) includes it: set `autoTracking: true`, or
an object with the options below, in its configuration.
## Events
Auto-tracking starts after boot, once `analytics` consent is granted, and sends
each event to every active provider whose
[`events` option](/guides/events/#routing-events) includes it. The Meta and
TikTok pixels leave them out by default. `autoTracking()` from `consentlayer/auto-tracking` enables
every kind with its defaults. An object that turns kinds on enables only
those: `autoTracking({ clicks: { mode: 'attribute' } })` sends clicks alone,
while `autoTracking({ scroll: false })` keeps every kind but scroll.
| Option | Default | Event | Params |
| ------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `pageViews` | `true` | `consentlayer.page_session_start`, on start and on every route change | `session_id` |
| `clicks` | `{ mode: 'all', attribute: 'data-track' }` | `consentlayer.ui_click`, for clicks on `button`, `[role="button"]`, `a`, `input[type="submit"]` and `input[type="button"]` | `ui_target`, `ui_element`, `ui_id`, `path`, `session_id` |
| `scroll` | `{ thresholds: [25, 50, 75, 100] }` | `consentlayer.scroll_depth`, once per threshold and page session | `depth`, `session_id` |
| `timeOnPage` | `{ intervals: [5, 15, 30, 60, 120] }` | `consentlayer.time_on_page`, after each interval in seconds | `seconds`, `session_id` |
With `clicks: { mode: 'attribute' }`, only elements carrying the attribute
are tracked. `ui_target` is the first non-empty value of the attribute and
`aria-label`, never the element's text. The
event names derive from [`names.prefix`](/guides/names/).
## Page sessions
Each route is a page session. `analytics.newPageSession()` starts a new one: it
sends `page_session_start` again and restarts the scroll and time counters. The
consent controller behind every framework integration calls it when the route
key changes, as described in [Add the banner](/start/react/#3-add-the-banner);
call it yourself when you track routes without one.
## Errors
`createAnalytics()` throws if `autoTracking` is not the value `autoTracking()`
returns, such as the `true` of earlier versions. An error thrown while
auto-tracking starts or begins a page session is reported to `onError` with
scope `auto-tracking`; providers and consent keep working.
# Reduce bundle size
> How ConsentLayer keeps out of your bundle what you don't use, and what you can do to ship less.
ConsentLayer is built so that your bundle contains only the parts you import.
The package is ES modules only and declares `"sideEffects": false`, so any
bundler that tree-shakes, such as Vite, Rollup, esbuild, webpack or
Turbopack, drops the exports you don't use. The package's CI measures the
entry points on every change; see [Bundle size](/#bundle-size) for the current
figures.
## Import only what you use
Each provider, consent adapter and server destination is its own entry point,
and the root entry exports none of them. Importing `ga4()` never bundles
`meta()` or `posthog()`:
```ts
import { createAnalytics } from 'consentlayer'
import { ga4 } from 'consentlayer/providers/ga4'
```
A provider turned off by an `undefined` ID, such as
`meta({ pixelId: process.env.NEXT_PUBLIC_FACEBOOK_PIXEL_ID })` without the
variable, still ships its code. To keep a provider out of the bundle, don't
import it.
From the root entry, only the exports you use are bundled; the
[bundle size table](/#bundle-size) lists `createAnalytics()` on its own. The
consent engine, the event queue and `debug` logging are part of
`createAnalytics()` whether or not you turn them on. Auto-tracking is a
separate entry, `consentlayer/auto-tracking`, bundled only when you import
`autoTracking()`.
## Load vendor SDKs on demand
`posthog()` and `sentry()` load `posthog-js` and `@sentry/browser` with a
dynamic `import()`, which your bundler splits into separate chunks, fetched
only when the provider loads. PostHog session recording, Sentry Session
Replay and Sentry tracing are further chunks, fetched only when you turn them
on.
`defer` postpones loading these SDKs until the browser is idle, the first
interaction or a delay, so they don't compete with your page's first load:
```ts
posthog({ apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY, defer: 'idle' })
```
See [Loading SDKs later](/guides/providers/#loading-sdks-later).
The other providers ship no SDK. Most inject their vendor's script tag in the
browser, from the vendor's servers, once consent allows it. `vercel()` injects
no script: it calls the `track` function you pass from `@vercel/analytics`.
## Prefer npm over the script tag
The [script tag](/start/script-tag/) bundles every provider and adapter into
one file, because it can't know which ones a page uses; its size is in the
[bundle size table](/#bundle-size). Installed from npm, you ship only the core
and the providers you import.
## Measure your own bundle
To see what ConsentLayer adds to your application, use your bundler's
analyzer, such as
[`@next/bundle-analyzer`](https://www.npmjs.com/package/@next/bundle-analyzer)
or [`rollup-plugin-visualizer`](https://www.npmjs.com/package/rollup-plugin-visualizer)
for Vite, and look for `consentlayer` in the output.
# ConsentLayer and c15t
> How ConsentLayer compares with c15t, what each one has that the other does not, and how the two can run together.
[c15t](https://c15t.com) is an open-source consent management platform for
JavaScript and the project closest to ConsentLayer. The two overlap, but they
solve different parts of the problem. c15t collects consent: it ships the
banner, stores each choice and loads third-party scripts once they are
allowed. ConsentLayer acts on consent: you bring the banner, and it loads the
analytics providers and sends your events to them, in the browser and from
your server.
This page compares the two as of October 2026. The c15t column is based on
[its repository](https://github.com/c15t/c15t) and
[documentation](https://c15t.com/docs), not on running it; check them for
anything that has changed since.
## Comparison
| Area | c15t | ConsentLayer |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Banner and settings UI | Prebuilt banner, preference dialog and theme system, plus headless hooks | A banner in the [script tag](/start/script-tag/) only. On npm you build your own on [`useConsentController()`](/guides/consent-ui/) |
| Translations | 35 languages | English, with every string overridable |
| Consent records | A hosted service (Inth) with a dashboard and audit history, or a self-hosted backend on PostgreSQL, MySQL or SQLite | Appended to your own database through a [store](/guides/consent-records/), with built-in stores for Supabase, Prisma, Drizzle and Upstash. No dashboard |
| Integrations | More than 40 script loaders: analytics, ad pixels, chat, monitoring and embeds | 9 [providers](/guides/providers/), plus your own with [`definePlugin()`](/internals/plugins/) |
| Tracking API | Loads each vendor's script; you call each vendor's API | One `track`, `page` and `identify` API, translated for each provider, with [auto-tracking](/guides/auto-tracking/) |
| Server-side events | Not in its documentation | GA4 Measurement Protocol, Meta Conversions API, TikTok Events API and server-side GTM, with [consent checked on the server](/guides/server-side-tracking/) |
| IAB TCF | A full CMP for TCF 2.4 and GPP | [Reads](/guides/adapters/tcf/) the choice of a TCF CMP on the page; it is not a CMP |
| Global Privacy Control | Supported | [Supported](/guides/consent-ui/#global-privacy-control) |
| Google Consent Mode v2 | Supported | [Supported](/guides/adapters/google-consent-mode/) |
| Rules per visitor region | Built in, such as opt-in in the EU and opt-out in some US states | [Planned](/guides/roadmap/#privacy-laws-beyond-the-gdpr); the defaults follow the opt-in model everywhere |
| Frameworks | React, Next.js, Vue, Nuxt, Astro, TanStack Start, Svelte, SvelteKit and a script tag | React, Next.js, Vue, Nuxt, Svelte, SvelteKit, Solid, SolidStart, Angular and a script tag |
| Runtime dependencies | Not stated | None |
| License | Apache 2.0 | MIT |
| Maturity | About 2,000 GitHub stars and more than 2,100 sites, maintained by a company (Inth) | A new 1.0 release with little production history |
## What c15t has that ConsentLayer does not
- A finished consent UI on every framework, themed and translated into 35
languages. With ConsentLayer you design and translate the banner yourself,
unless you use the script tag.
- A consent platform you don't have to run: hosted consent records with a
dashboard and audit history. ConsentLayer writes records to your database
and leaves querying, retention and deletion to you.
- A full IAB TCF and GPP implementation. Its documentation describes using
Inth's registered CMP ID with the hosted service and its stock IAB
components; other setups need their own registration with IAB Europe.
ConsentLayer only reads a CMP's decision.
- Rules per visitor region, which ConsentLayer has only on its
[roadmap](/guides/roadmap/).
- Over four times as many integrations, including chat widgets, embeds such as
YouTube and Google Maps, and pixels for LinkedIn, Reddit, Snapchat, X and
Microsoft.
- Support for Astro and TanStack Start.
- A track record: it is used in production by thousands of sites and
maintained by a company. ConsentLayer has no production history yet.
Each of these features, including hosted consent records, is on the
[roadmap](/guides/roadmap/#planned).
## What ConsentLayer has that c15t does not
- One tracking API for every provider. `analytics.track('purchase', ...)`
reaches GA4, Meta, TikTok and PostHog, each in its own format and only while
its consent category is granted.
- Server-side events. The same events are forwarded to GA4, Meta, TikTok and
server-side GTM from your server, which can accept only the consent the
visitor's signed consent cookie proves.
- Support for Solid, SolidStart and Angular, which c15t's documentation does
not list.
- No runtime dependencies and no backend to host. Consent records are
optional.
## Using both
c15t can collect consent while ConsentLayer tracks. When c15t runs as an IAB
TCF CMP, the [`tcf()`](/guides/adapters/tcf/) adapter reads its decision and
ConsentLayer follows it. This combination has not been tested; the adapter is
tested against a simulated `__tcfapi`. Without TCF mode, call
[`analytics.consent.update()`](/guides/events/#consent) whenever c15t reports a
change.
Neither project makes a setup comply with a law; see the
[legal notice](/guides/legal/).
# Consent records
> Keep proof of each consent choice in your own database, and let the server trust only recorded consent.
This page covers consent records: the record schema, how long each piece of
consent data is kept, the consent route and its signed cookie, the built-in
stores, and what remains your responsibility.
A regulator can ask you to demonstrate that a visitor consented. With a
consent endpoint configured, ConsentLayer posts each choice to a route on your
server, which appends a record to your database and answers with a signed
cookie. The [tracking route](/guides/server-side-tracking/) can then read
consent from that cookie instead of the request body, so a request claiming
consent the visitor never gave is treated as denied.
## Records
Each choice appends one record. Records are never updated or deleted by
ConsentLayer.
| Field | Column | Type | Content |
| --------------- | ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `consentId` | `consent_id` | `string` | A random UUID identifying the browser. It is kept in the signed cookie, so all of one browser's choices share it. |
| `createdAt` | `created_at` | `Date` | When the server received the choice. |
| `consent` | `consent` | `ConsentState` | Every category, such as `{ "preferences": false, "analytics": true, "marketing": false }`, plus `sale_of_data` when it is set. |
| `policyVersion` | `policy_version` | `string` | The privacy policy version, from the route's options. |
| `userId` | `user_id` | `string`, optional | The signed-in user, when your `userId` option returns one. |
| `restored` | `restored` | `boolean` | `true` when the browser sent a choice it had already stored, because its consent cookie was missing. The visitor did not choose at `createdAt`; their original choice is an earlier record under another `consentId`. See [Lost cookie](#the-consent-cookie). |
A choice is recorded when it changes the stored consent: through the banner,
`analytics.consent.update()` or `setConsent()`, or through a consent
adapter's subscription, such as Shopify's `visitorConsentCollected` event.
A stored choice is also sent again, as a `restored` record, when its cookie
is lost.
Nothing is recorded when a stored choice is restored on a later visit, when a
choice is read from an adapter at bootstrap, when another tab changes the
stored choice, or when a submitted choice is identical to the current one.
## How long data is kept
ConsentLayer never deletes a consent record. Every other piece of consent data
has a fixed lifetime:
| Data | Where | Kept for |
| ----------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Consent records | Your database, through the [store](#stores) | Until you delete them. See [Retention and deletion](#retention-and-deletion). |
| The consent cookie, `__Host-consentlayer_proof` | The visitor's browser | 400 days from the last recorded choice (`Max-Age=34560000`), the longest lifetime browsers allow, or until the visitor clears cookies. |
| The `consentId` | Inside the consent cookie | As long as the cookie. Each recorded choice reuses the ID from a valid cookie; without one, a new ID is generated. |
| The visitor's choice | `localStorage`, under `consentlayer_consent` | Until the visitor changes it or clears site data, unless you set [`consent.maxAgeDays`](/guides/consent-ui/#asking-again), after which they are asked again. |
The cookie and the stored choice expire independently: with the defaults, a
choice can outlive its cookie. See the lost-cookie case under
[The consent cookie](#the-consent-cookie).
## Setup
Generate a secret of at least 32 characters, for example with
`openssl rand -base64 32`, and set it as `CONSENT_SECRET` on the server only.
The consent route and the tracking route must use the same secret. Both
check it at the start of each request rather than when they are created, so
`next build` succeeds where the secret exists only at runtime.
```ts
// app/api/consent/route.ts
import { createConsentHandler, postgresStore } from 'consentlayer/server'
import { sql } from '@/lib/db'
import { auth } from '@/lib/auth'
import { POLICY_VERSION } from '@/lib/policy'
export const POST = createConsentHandler({
store: postgresStore(sql),
secret: process.env.CONSENT_SECRET,
policyVersion: POLICY_VERSION,
userId: async () => (await auth())?.user.id,
// categories: ['functional'], if you declared custom categories
})
```
```ts
// lib/analytics.ts
export const analytics = createAnalytics({
server: { endpoint: '/api/analytics', consentEndpoint: '/api/consent' },
// ...
})
```
```ts
// app/api/analytics/route.ts
export const POST = createAnalyticsHandler({
destinations: [
metaCapi({ pixelId: process.env.NEXT_PUBLIC_FACEBOOK_PIXEL_ID, accessToken: process.env.META_TOKEN }),
],
consentCookie: { secret: process.env.CONSENT_SECRET },
})
```
`server.consentEndpoint` must be an HTTPS URL or a path starting with `/`;
`undefined` or `''` turns recording off, and any other value throws when
`createAnalytics` is called. Each recorded choice is
posted as `{ "consent": { … } }` with `fetch`. A network failure or non-2xx
response is reported to `onError` with scope `consent-adapter` and provider ID
`consent-endpoint`; the choice still applies in the browser. A failed
[lost-cookie](#the-consent-cookie) restore is reported with scope
`server-forward` and the message
`Restoring the consent cookie at failed`.
createConsentHandler options
| Option | Type | Default | Description |
| --------------- | --------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `store` | `ConsentStore` | Required | Where records are appended. |
| `secret` | `string \| undefined` | Required | The HMAC key, at least 32 characters. |
| `policyVersion` | `string` | Required | Written to every record and to the cookie. Pass the same value to `createAnalytics({ consent: { policyVersion } })`; a new major version asks visitors again. See [Asking again](/guides/consent-ui/#asking-again). |
| `categories` | `string[]` | `[]` | Your custom categories, as passed to `createAnalytics({ categories })`. Categories not listed are dropped from the record. |
| `userId` | `(request: Request) => string \| undefined \| Promise` | None | Returns the signed-in user from your session. The request body is never used for it. |
| `cookie` | `string` | `'__Host-consentlayer_proof'` | The cookie name. The default is `'consentlayer_proof'` on `localhost`, `127.0.0.1` and `[::1]`. |
| `onError` | `(error: unknown) => void` | None | Receives unexpected errors, such as a store failure. |
### The consent route
`createConsentHandler` returns a web-standard
`(request: Request) => Promise`. For each request it:
1. Checks the origin as the tracking route does. A request without an
`Origin` or `Referer` header, or from another origin, gets 403.
2. Reads the body. A `Content-Type` other than `application/json` gets 415, a
body over 4 KB (4,096 bytes) gets 413, and an empty body or invalid JSON
gets 400.
3. Validates `consent`: every built-in category and every category in
`categories` must be a boolean, and `sale_of_data`, if present, a boolean.
Otherwise it answers 400.
4. Reuses the `consentId` from a valid existing cookie, or generates one with
`crypto.randomUUID()`.
5. Calls `userId`, then appends the record to the store, with `restored`
set when the body has `restored: true`.
6. Answers 204 with the signed cookie.
Responses have no body. If the store or `userId` throws, the error is passed
to `onError`, the response is 500 and no cookie is set.
### The consent cookie
```http
Set-Cookie: __Host-consentlayer_proof=.; Path=/; Max-Age=34560000; HttpOnly; SameSite=Lax; Secure
```
`Secure` is left out when the request's host is `localhost`, `127.0.0.1` or
`[::1]`, because Safari does not store `Secure` cookies on
`http://localhost`. Browsers accept a `__Host-` cookie only with `Secure`, so
there the default name is `consentlayer_proof`. The handler sets the cookie
only when the request's `Origin` equals the origin of the request URL, so the
host it checks is always the one the visitor's browser sees. Behind a proxy
that rewrites the host or protocol, the handler answers 403 instead; configure
the proxy, or your framework, to keep the public origin in the request URL.
`Max-Age` is 400 days, the longest lifetime browsers allow. The payload is the
base64url-encoded JSON of `{ consentId, consent, policyVersion }`; the
signature is the base64url-encoded HMAC-SHA-256 of the encoded payload, keyed
with the secret. Both use the URL-safe alphabet without padding.
With `consentCookie` set, the cookie can only narrow the `consent` in the
request body: a category counts as granted only when both the cookie and the
body grant it. A missing cookie, a malformed value or a signature that does not
verify counts as every category denied. In your own server code, read the
verified contents with `readConsentCookie(request, { secret })`, which
resolves to `{ consentId, consent, policyVersion }` or `null`.
Edge cases
- **Before the cookie arrives.** Events forwarded between a choice and the
consent route's response carry no cookie yet, so they count as denied.
- **Lost cookie.** The cookie can be cleared or expire while the browser
still stores the choice. The tracking route then answers with the header
`consentlayer-consent: missing`, and the browser posts the stored choice to
`server.consentEndpoint` with `restored: true`, once per page load. The
consent route records it with a new `consentId` and sets a new cookie. The
event that revealed the loss counts as denied, as do events sent before the
new cookie arrives. Without `server.consentEndpoint`, events count as denied
until the visitor makes a choice that differs from the stored one.
- **Secret rotation.** Changing the secret invalidates every existing cookie,
so each browser restores its cookie as for a lost cookie, adding one
`restored` record per browser.
- **Missing or short secret.** `createConsentHandler()` answers every request
with 500 and passes `[consentlayer] the consent secret is not set` or
`[consentlayer] the consent secret needs at least 32 characters` to
`onError`. The check runs before the body is read, so no record is stored.
`createAnalyticsHandler({ consentCookie })` answers 500 for a short secret;
with the secret unset it logs a `console.warn` once when created and treats
every event as denied, so environments without `CONSENT_SECRET` send
nothing.
- **Policy version.** The cookie is not checked against the current
`policyVersion`; a cookie signed under an earlier version still verifies.
## Stores
A store implements one method, `append(record: ConsentRecord): Promise`.
Every SQL store writes to the same `consent_records` table:
```sql
create table consent_records (
id bigint generated always as identity primary key,
consent_id text not null,
created_at timestamptz not null,
consent jsonb not null,
policy_version text not null,
user_id text,
restored boolean not null default false
);
create index on consent_records (consent_id);
create index on consent_records (user_id);
```
To make the table append-only in the database as well, revoke `update` and
`delete` on it from the role your application connects with.
### Postgres
`postgresStore(sql)` takes any `sql` tagged template that returns a promise:
postgres.js (`postgres(url)`), Neon (`neon(url)`) or Vercel Postgres (`sql`
from `@vercel/postgres`). It runs a parameterized `insert`, casting
`created_at` to `timestamptz` and `consent` to `jsonb`. The table name is
fixed to `consent_records`, because tagged templates cannot parameterize
identifiers portably.
### Supabase
```ts
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
supabaseStore(supabase) // or supabaseStore(supabase, { table: 'consent_records' })
```
`supabaseStore` inserts one row with snake_case columns into `consent_records`,
or the table named by `table`, and throws
`[consentlayer] supabase insert failed`, with Supabase's error as `cause`,
when the insert returns an error. Create the table above in the SQL editor
and enable row level security with no policies, so the browser's anon key can
neither read nor write it. Use the service role key on the server only.
### Prisma
```prisma
model ConsentRecord {
id BigInt @id @default(autoincrement())
consentId String @map("consent_id")
createdAt DateTime @map("created_at") @db.Timestamptz
consent Json
policyVersion String @map("policy_version")
userId String? @map("user_id")
restored Boolean @default(false)
@@index([consentId])
@@index([userId])
@@map("consent_records")
}
```
```ts
prismaStore(prisma.consentRecord)
```
`prismaStore` calls `create` on the model with the record's camelCase fields,
setting `userId` to `null` when there is none.
### Drizzle
```ts
import { bigint, boolean, index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const consentRecords = pgTable(
'consent_records',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
consentId: text('consent_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
consent: jsonb('consent').notNull(),
policyVersion: text('policy_version').notNull(),
userId: text('user_id'),
restored: boolean('restored').notNull().default(false),
},
(table) => [index().on(table.consentId), index().on(table.userId)],
)
```
```ts
drizzleStore(db, consentRecords)
```
`drizzleStore` calls `db.insert(table).values(…)` with the record's camelCase
fields, setting `userId` to `null` when there is none.
### Upstash Redis and Vercel KV
```ts
import { Redis } from '@upstash/redis'
upstashStore(Redis.fromEnv()) // or upstashStore(kv) with @vercel/kv
```
Each consent ID has a list, `consent:`. Every choice is appended
with `RPUSH` as the record's JSON, with `createdAt` as an ISO 8601 string, so
the list is ordered oldest first. Change the key prefix with
`upstashStore(redis, { prefix })`.
### Your own
```ts
import type { ConsentStore } from 'consentlayer/server'
const store: ConsentStore = {
async append(record) {
await myDb.insert(record)
},
}
```
A rejected promise makes the consent route answer 500 without setting the
cookie.
## Retention and deletion
ConsentLayer only appends records. Choosing how long to keep them, deleting
old ones and deleting a person's records when they ask are your
responsibility, so schedule the deletion yourself, for example in a cron job.
With the Postgres, Prisma, Drizzle and Supabase stores, records live in the
`consent_records` table, unless you renamed it. To delete records older than a
retention period:
```sql
delete from consent_records where created_at < now() - interval '3 years';
```
A record proves the consent that a browser currently has, so deleting by age
alone can remove the proof for a choice still in force. To keep each browser's
latest record regardless of its age:
```sql
delete from consent_records r
where r.created_at < now() - interval '3 years'
and exists (
select 1 from consent_records newer
where newer.consent_id = r.consent_id and newer.created_at > r.created_at
);
```
To delete a person's records, find a browser's records by `consent_id`, which
you can read in your own route with
[`readConsentCookie(request, { secret })`](#the-consent-cookie), and a
signed-in user's by `user_id`:
```sql
delete from consent_records where consent_id = $1 or user_id = $2;
```
With the Upstash Redis and Vercel KV store, each browser's records are one
list, so `DEL consent:` deletes them. Redis cannot expire single
entries of a list; to delete by age, read the list with `LRANGE`, keep the
entries whose `createdAt` is recent enough, and write them back.
# Consent UI
> Build a consent banner and settings dialog on useConsentController, starting from a copy-paste recipe, and add custom consent categories.
import { Code } from '@astrojs/starlight/components'
import banner from '../../../components/ConsentBanner.tsx?raw'
import css from '../../../components/consent-banner.css?raw'
import vueBanner from '../../../../../example-nuxt/app/components/ConsentBanner.vue?raw'
import svelteBanner from '../../../../../example-sveltekit/src/lib/ConsentBanner.svelte?raw'
import solidBanner from '../../../../../example-solidstart/src/lib/ConsentBanner.tsx?raw'
import angularBanner from '../../../../../example-angular/src/app/consent-banner.ts?raw'
The npm package does not ship a consent banner, so its design, wording and
languages remain yours; only the [script tag](/start/script-tag/#3-the-banner)
comes with one. This page documents `useConsentController()`, the hook
that provides a banner's state and actions, gives a complete banner and
settings dialog to copy into your app, and explains how to add consent
categories of your own. The recipe is the banner the
[Playground](/playground/) runs.
:::caution[Not legal advice]
The recipe shows how to use the controller; it has not been legally reviewed.
The wording, layout and choices your banner needs depend on the law where you
and your visitors are, and are your responsibility; see the
[legal notice](/guides/legal/).
:::
## `useConsentController()`
`useConsentController()` is exported from `consentlayer/react`. Its state is
held by ``, so it must be called in a descendant of the
provider; elsewhere it throws
`useConsentController must be used within AnalyticsProvider`. Unlike
`useAnalytics()` and `useConsent()`, it has no fallback to the most recent
instance.
| Field | Type | Behavior |
| ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready` | `boolean` | `false` until `analytics.consent.bootstrap()` has settled. Render nothing while it is `false`, so that returning visitors don't see the banner flash. |
| `error` | `string \| null` | The rejection reason, converted to a string, if `bootstrap()` rejected. The banner is then shown as if no consent were stored. |
| `bannerVisible` | `boolean` | After bootstrap, `true` when no consent is stored or the stored choice is [stale](#asking-again), and the provider's `autoShowBanner` prop is `true` (the default). |
| `dialogVisible` | `boolean` | Whether the settings dialog is open. Initially `false`. |
| `categories` | `readonly ConsentCategory[]` | The categories the dialog shows, from `analytics.consent.categories`: the built-in ones, then your custom ones. |
| `prefs` | `Record` | The dialog's draft, one entry per category in `analytics.consent.categories`. Initialized from the stored consent, all `false` when there is none. |
| `setPrefs(next)` | `(next) => void` | Replaces the draft without saving it. |
| `gpc` | `boolean` | Whether the visitor's [Global Privacy Control](#global-privacy-control) signal is honored. Use it to tell the visitor so. |
| `acceptAll()` | `() => Promise` | Grants every category, then hides the banner and the dialog. While `gpc` is `true`, `marketing` stays denied. |
| `rejectNonEssential()` | `() => Promise` | Denies every category, then hides both. |
| `savePreferences()` | `() => Promise` | Saves `prefs`, then hides both. |
| `openDialog()`, `closeDialog()` | `() => void` | Show or hide the dialog. Closing it discards toggles that were not saved. |
| `showBanner()`, `hideBanner()` | `() => void` | Show or hide the banner. |
The three saving actions call `analytics.consent.setConsent()` and hide the
banner and dialog only once it resolves, which is after the consent adapters
and the `server.consentEndpoint` request have finished.
Edge cases
- `prefs` is overwritten whenever the consent state changes, including
changes made in another tab, by a consent adapter or by
`analytics.consent.update()`. A draft the visitor has not saved is lost when
that happens.
- The saving actions write only the toggleable categories. Because
`setConsent()` replaces the whole state, a `sale_of_data` value set
elsewhere is not kept.
- With `autoShowBanner={false}`, `bannerVisible` stays `false` until you call
`showBanner()`, even for visitors who have not chosen.
### Reopening the settings
Visitors must be able to change their choice later. Inside React, call
`openDialog()`, as the recipe's "Cookie settings" button does below. Outside
React, for example in a footer rendered by a CMS, call the open-settings
global, which `` defines when it mounts:
```html
```
The global's name follows [`names.prefix`](/guides/names/). With
`names.legacy`, `ACM_openConsent` is defined as well. The
`openConsentGlobalKey` prop of `` replaces both with the
name you pass.
### Asking again
A stored choice does not expire by default. Two options of
`createAnalytics({ consent })` make ConsentLayer ask the visitor again:
- **`maxAgeDays`** makes a choice stale once it is older than that many days.
The CNIL, for example, recommends asking again after six months.
- **`policyVersion`** makes a choice stale when it was made under another
major version of your privacy policy: the part before the first `.`.
```ts title="lib/analytics.ts"
export const analytics = createAnalytics({
providers,
consent: { maxAgeDays: 180, policyVersion: POLICY_VERSION },
})
```
Version your privacy policy as `major.minor` and keep the version in one
constant that the browser and the [consent route](/guides/consent-records/#the-consent-route)
both import:
```ts title="lib/policy.ts"
export const POLICY_VERSION = '3.2'
```
Raise the major version when the policy changes in a way visitors must agree
to again, such as a new purpose or a new recipient of their data. Raise the
minor version for edits that need no new consent, such as a new contact
address:
| Change | Visitors asked again | New records cite |
| ------------------ | -------------------- | ---------------- |
| `'3.1'` to `'3.2'` | No | `'3.2'` |
| `'3.2'` to `'4.0'` | Yes | `'4.0'` |
A choice made under `'3.1'` stays valid under `'3.2'`, and its record keeps
citing `'3.1'`, the version the visitor agreed to. A version without a `.`,
such as a date like `'2026-10-01'`, is entirely the major version, so any
change asks again.
Each choice is stored with the date it was made and the `policyVersion` in
effect. When `bootstrap()` finds a stale choice, `bannerVisible` becomes
`true` and `analytics.consent.isStale()` returns `true`. What the stale choice
means until the visitor answers depends on `onStale`:
| `onStale` | Consent until the visitor chooses | Providers |
| --------------------- | --------------------------------- | ---------------------------------------------- |
| `'discard'` (default) | `null`, as for a first visit | Only those with `requiredConsent: 'none'` run. |
| `'keep'` | The stale choice | Unchanged. The banner is shown on top. |
With `'keep'`, `prefs` starts from the stale choice, so a visitor who confirms
it without changes stores the same choice again. That confirmation counts as a
new choice: it is stored with the current date and `policyVersion` and, with
`server.consentEndpoint`, recorded again on the server.
Choices stored without a date or version
A choice stored before these options existed, or before you first set them,
carries no date or no `policyVersion`. It counts as stale for each option you
set, so turning on `maxAgeDays` or `policyVersion` asks every returning
visitor once. Choices supplied by a consent adapter, such as Shopify Customer
Privacy, are stored with the current date and version, since the adapter is
their source.
### Global Privacy Control
Global Privacy Control (GPC) is a browser signal by which a visitor asks sites
not to sell or share their data. A browser that sends it exposes
`navigator.globalPrivacyControl` as `true`. ConsentLayer honors it by keeping
the `marketing` category off unless the visitor switches it on:
- The banner is shown as usual. Analytics, preferences and custom categories
follow the visitor's choice.
- `acceptAll()` grants every category except `marketing`.
- An explicit `marketing: true`, from the settings dialog's toggle and
`savePreferences()`, or from `analytics.consent.update()` or `setConsent()`,
counts as consent and is stored like any other choice.
- Before the visitor chooses, nothing changes: consent is `null`, so
`marketing` is already denied.
`gpc` on `useConsentController()`, and `analytics.consent.gpc`, are `true`
while the signal is honored, so your banner can say so:
```tsx
{gpc &&
Your browser's Global Privacy Control signal is on, so marketing is off.
}
```
To ignore the signal, set `gpc: false`:
```ts title="lib/analytics.ts"
export const analytics = createAnalytics({ providers, consent: { gpc: false } })
```
Stored choices and consent adapters
The signal is read once, when `createAnalytics()` runs, and only changes what
"Accept all" grants. A choice already stored, or one a consent adapter such
as [Shopify Customer Privacy](/guides/adapters/shopify/) supplies at
bootstrap, was made by the visitor and is kept as it is, `marketing`
included. The Shopify adapter supplies a choice only when the visitor has
made one in Shopify.
The server follows the consent each event carries, so it matches the
browser; see
[Server-side tracking](/guides/server-side-tracking/#global-privacy-control).
## Recipe
Copy both files, then adapt the wording and the styles. The `categories`
object needs a label and description for every category in
`ConsentCategory`, and TypeScript rejects it if one is missing or undeclared.
`functional` is the Playground's [custom category](#custom-categories); delete
its entry unless you declare it. The "Necessary" row is not a category, since
necessary cookies need no consent, so the dialog renders it checked and
disabled.
Render the banner once inside the provider, and give visitors a way back to
the settings:
```tsx title="components/analytics-shell.tsx"
'use client'
import { AnalyticsProvider, useConsentController } from 'consentlayer/react'
import type { ReactNode } from 'react'
import { analytics } from '../lib/analytics'
import { ConsentBanner } from './consent-banner'
function CookieSettingsButton() {
const { openDialog } = useConsentController()
return (
)
}
export function AnalyticsShell({ children }: { children: ReactNode }) {
return (
{children}
)
}
```
In a Next.js app, import `AnalyticsProvider` from `consentlayer/next` instead
so page views follow navigation, as shown in [Next.js](/start/nextjs/). The banner's text is plain strings in your
component, so translate it with the i18n library your app already uses.
## Vue
`useConsentController()` from `consentlayer/vue` has the same fields, with the
state as read-only refs: read `bannerVisible.value` in `
```
`astro:page-load` also fires for the first page; `setRoute()` ignores a call
with the key it already has, so that page view is not sent twice. Without
``, the event never fires and the `setRoute()` call in
`src/analytics.ts` covers each page load.
# Next.js
> Set up ConsentLayer in a Next.js App Router application.
This guide sets up ConsentLayer in the Next.js App Router: a module-level
analytics instance, a client component that provides consent state and renders
your consent banner, page views on every navigation, event tracking, and error
reporting on the client and the server. The
[example app](https://github.com/nicolopadovan/consentlayer/tree/main/apps/example-next)
in the repository is a complete implementation of this guide, built on
Next.js 16.1 and React 19.1.
Next.js uses the React binding, `consentlayer/react`, plus `consentlayer/next`
for the router. This page covers what is specific to Next.js; the
[React](/start/react/) guide describes the provider, the hooks and the error
boundary in full.
It assumes you have [installed](/start/installation/) `consentlayer` and the
SDKs of the providers you use.
## 1. Create the instance
Create the instance once, at module level, in a file that client components
import. Pass each provider its ID straight from the environment:
```ts
// lib/analytics.ts
import { createAnalytics } from 'consentlayer'
import { ga4 } from 'consentlayer/providers/ga4'
import { meta } from 'consentlayer/providers/meta'
import { autoTracking } from 'consentlayer/auto-tracking'
export const analytics = createAnalytics({
providers: [
ga4({ measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID }),
meta({ pixelId: process.env.NEXT_PUBLIC_FACEBOOK_PIXEL_ID }),
],
autoTracking: autoTracking(),
})
```
Read each `NEXT_PUBLIC_*` variable by its full, literal name, as above. Next.js
inlines only literal `process.env.NEXT_PUBLIC_X` reads into browser code, so a
destructured or dynamically indexed read is `undefined` in the browser.
A provider whose ID is not set is turned off, so the same code runs in every
environment. [Create the instance](/start/react/#1-create-the-instance) in the
React guide covers ID validation, `autoTracking` and turning analytics off in
development.
## 2. Add the provider
Create a client component that wraps your application in
``:
```tsx
// components/analytics-shell.tsx
'use client'
import { AnalyticsProvider } from 'consentlayer/next'
import type { ReactNode } from 'react'
import { analytics } from '../lib/analytics'
export function AnalyticsShell({ children }: { children: ReactNode }) {
return {children}
}
```
`` from `consentlayer/next` is the provider from
`consentlayer/react` with the route taken from the App Router: it reads
`usePathname()` and `useSearchParams()` and sends a page view whenever either
changes, so navigating from `/shop` to `/shop?page=2` sends one. It accepts
every prop of the React provider except `routeKey`. The shell is a client
component because `analytics` holds functions, which a Server Component cannot
pass to a client component.
On mount, it bootstraps consent, boots the providers and sends the first page
view, as described in [Add the
provider](/start/react/#2-add-the-provider); that section also lists its
props.
Render the shell in the root layout. No `` boundary is needed:
```tsx
// app/layout.tsx
import type { ReactNode } from 'react'
import { AnalyticsShell } from '../components/analytics-shell'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
)
}
```
Why there is no <Suspense> around your pages
During static rendering, `useSearchParams()` makes the nearest ``
boundary above it render only on the client. The usual pattern, a shell that
calls `useSearchParams()` wrapped in `` in the root
layout, therefore moves every page into that boundary: the prerendered HTML
contains the fallback instead of the page. `consentlayer/next` calls
`useSearchParams()` in an internal component that renders nothing, inside its
own `` boundary, so only that component waits for the client and
your pages are prerendered as usual. Page views are held until it reports the
first route, so the first page is not counted twice.
`consentlayer/next` also works in the Pages Router: render the same
`` in `pages/_app.tsx`. There, page views are held until
`router.isReady`, so a statically optimized page that hydrates without its
query string is counted once, with the full URL. In other frameworks, use
`` from `consentlayer/react` and pass `routeKey` yourself:
any string that changes on navigation, or `null` while the route is not yet
known, as the [React](/start/react/) guide shows.
## 3. Add the banner
Copy the [consent banner recipe](/guides/consent-ui/) into
`components/consent-banner.tsx`. Then render the banner in the shell, and give
visitors a way to reopen their settings:
```tsx
// components/analytics-shell.tsx
'use client'
import { AnalyticsProvider } from 'consentlayer/next'
import { useConsentController } from 'consentlayer/react'
import type { ReactNode } from 'react'
import { analytics } from '../lib/analytics'
import { ConsentBanner } from './consent-banner'
function CookieSettingsButton() {
const { openDialog, bannerVisible } = useConsentController()
if (bannerVisible) return null
return (
)
}
export function AnalyticsShell({ children }: { children: ReactNode }) {
return (
{children}
)
}
```
`useConsentController()` comes from `consentlayer/react` and returns the state
and actions that `` holds; `bannerVisible` is also `true`
when the stored choice is [stale](/guides/consent-ui/#asking-again), unless
you pass `autoShowBanner={false}`.
Meta Pixel and GTM without JavaScript, and Shopify Customer Privacy
`` from `consentlayer/react` renders Meta's `