Skip to content

Upgrading to 1.0

ConsentLayer 1.0 replaces @nicolopadovan/analytics-consent-manager, which was published to GitHub Packages as 0.1.0, 1.0.0-rc.0 and 1.0.0-rc.1. This guide covers the upgrade from any of those versions. The module-level singletons, the separate consent engine and analytics manager, and the four React components are replaced by one instance created with createAnalytics() and one <AnalyticsProvider>. Event names follow GA4, the bundled consent UI is removed, and browser-to-server forwarding uses a new route handler.

Change What to do
The package is renamed and published to npmjs.com Install consentlayer and update every import
The engine, manager, default runtime and React components are replaced Create one instance with createAnalytics()
Storage keys, DOM events and globals use the consentlayer prefix Opt in to the legacy names or visitors see the banner again
Auto-tracking events are renamed from acm.* to consentlayer.* Update dashboards or set names.prefix
An auto-tracking config that names a kind turns on only the kinds it names Name every kind you want
A click’s ui_target no longer falls back to its name or text Label tracked elements
Provider factories are renamed; GA4 and Meta no longer load before consent Rename the providers
The track* e-commerce helpers are removed; events use GA4 names Replace the helpers with track() and the param builders
track() no longer requires analytics consent Check what each provider receives
consentlayer/ui and its stylesheet are removed Build your own banner from the recipe
“Reject non-essential” and “Save” store preferences: false Review consent behavior
setConsent() rejects non-boolean values Validate consent you read yourself
meta({ serverEndpoint }) and the /server helpers are removed Replace your server routes
The validators and renderMetaPixelNoscriptHtml are removed Other removed exports
The plugin contract is renamed and its lifecycle changes Update custom plugins

Remove the old package and install ConsentLayer:

Terminal window
pnpm remove @nicolopadovan/analytics-consent-manager
pnpm add consentlayer

Replace @nicolopadovan/analytics-consent-manager with consentlayer in every import. ConsentLayer is published to the public npm registry, so you can remove the @nicolopadovan:registry=https://npm.pkg.github.com line and its token from .npmrc unless another package needs them.

react and react-dom are now optional peer dependencies, needed only for consentlayer/react. The new PostHog and Sentry providers declare their SDKs as optional peer dependencies too; see Installation.

createAnalytics() replaces createConsentEngine(), createAnalyticsManager() and the default runtime that the module-level functions used. It creates the consent engine, the local storage for consent and identity, and the manager, and returns one object with the whole API. <AnalyticsProvider> replaces <ConsentProvider>, <ConsentControllerProvider>, <AnalyticsManager> and <AutoTracking>.

lib/analytics.ts
import { createAnalytics } from 'consentlayer'
import { ga4 } from 'consentlayer/providers/ga4'
import { autoTracking } from 'consentlayer/auto-tracking'
export const analytics = createAnalytics({
providers: [ga4({ measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID })],
autoTracking: autoTracking(),
names: { legacy: true },
})
app/providers.tsx
'use client'
import { AnalyticsProvider } from 'consentlayer/next'
import { analytics } from '@/lib/analytics'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AnalyticsProvider analytics={analytics}>
{children}
</AnalyticsProvider>
)
}

<AnalyticsProvider> from consentlayer/next takes the route from the App Router, so it replaces the routeKey={usePathname()} you passed before and also counts query-string changes: a search or filter page that writes its state to the URL sends a page view for each change. To keep one page view per path, use <AnalyticsProvider> from consentlayer/react with routeKey={usePathname()} instead. Remove any <Suspense> boundary you added around the provider for useSearchParams(): it made every page inside it render only on the client. The Next.js guide explains why. In other React apps, keep <AnalyticsProvider> from consentlayer/react and pass routeKey yourself.

Create the instance once, at module level, rather than inside a component. Code outside React, such as app/global-error.tsx, imports the same instance, and createAnalytics() logs a warning when a second instance on the page uses different names.

Full before and after for a Next.js app

Before:

lib/analytics.ts
import {
createAnalyticsManager,
createConsentEngine,
createLocalStorageConsentStorage,
setDefaultConsentEngine,
setDefaultRuntimeAnalyticsManager,
} from '@nicolopadovan/analytics-consent-manager'
import { createGa4Adapter } from '@nicolopadovan/analytics-consent-manager/providers/ga4'
import { createMetaAdapter } from '@nicolopadovan/analytics-consent-manager/providers/meta'
export const consentEngine = createConsentEngine({
storage: createLocalStorageConsentStorage({ key: 'acm_consent' }),
})
export const manager = createAnalyticsManager({
consentEngine,
providers: [
createGa4Adapter({ measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID! }),
createMetaAdapter({
pixelId: process.env.NEXT_PUBLIC_FACEBOOK_PIXEL_ID!,
serverEndpoint: '/api/meta',
}),
],
})
setDefaultConsentEngine(consentEngine)
setDefaultRuntimeAnalyticsManager(manager)
app/providers.tsx
'use client'
import {
AnalyticsManager,
AutoTracking,
ConsentControllerProvider,
ConsentProvider,
} from '@nicolopadovan/analytics-consent-manager/react'
import { CookieBanner, CookieDialog } from '@nicolopadovan/analytics-consent-manager/ui'
import '@nicolopadovan/analytics-consent-manager/ui/styles.css'
import { usePathname } from 'next/navigation'
import { consentEngine, manager } from '@/lib/analytics'
export function Providers({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<ConsentProvider engine={consentEngine}>
<ConsentControllerProvider>
<AnalyticsManager manager={manager} routeKey={pathname} lazy={{ mode: 'idle' }} />
<AutoTracking manager={manager} routeKey={pathname} />
{children}
<CookieBanner />
<CookieDialog />
</ConsentControllerProvider>
</ConsentProvider>
)
}

After:

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 }),
],
server: { endpoint: '/api/analytics' },
autoTracking: autoTracking(),
names: { legacy: true },
})
app/providers.tsx
'use client'
import { AnalyticsProvider } from 'consentlayer/next'
import { ConsentBanner } from '@/components/consent-banner'
import { analytics } from '@/lib/analytics'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AnalyticsProvider analytics={analytics} boot={{ mode: 'idle' }}>
{children}
<ConsentBanner />
</AnalyticsProvider>
)
}

ConsentBanner is the Consent UI recipe, and /api/analytics is the route described in Replace your server routes.

Removed Replacement
createConsentEngine(options) createAnalytics({ consent: { storage, adapters, initialConsent, broadcast } })
createAnalyticsManager(options) createAnalytics({ providers, identityStorage, enabled, debug, onError })
setDefaultConsentEngine, getDefaultConsentEngine, setDefaultRuntimeAnalyticsManager, getDefaultRuntimeAnalyticsManager Import the instance
setDefaultIdentityStorage, getDefaultIdentityStorage, useDefaultIdentityStorage createAnalytics({ identityStorage }); the default is local storage
registerProvider(adapter) providers: [...], or analytics.registerProvider(plugin)
updateConsent(consent) analytics.consent.setConsent(consent), or analytics.consent.update(partial) to change some categories
trackEvent(name, params, options) analytics.track(name, params, options)
trackPage(payload) analytics.page(payload)
enableAutoTracking(config) createAnalytics({ autoTracking: autoTracking(config) }), from consentlayer/auto-tracking
newPageSession() analytics.newPageSession(); <AnalyticsProvider> calls it when routeKey changes
saveIdentity({ email, phone, external_id }) analytics.identify(external_id, { email, phone })
getIdentity() No replacement; the identity is passed to plugins as ctx.identity
defaultEventDestinations Events go to every provider; limit one event with { only: [...] } and a provider’s events with its events option
defaultRuntimePreset getNames(), which returns the configured names
<ConsentProvider>, <ConsentControllerProvider>, <AnalyticsManager>, <AutoTracking> <AnalyticsProvider analytics={analytics}>
<AnalyticsManager lazy={...}> <AnalyticsProvider boot={...}>, with the same mode and timeoutMs
useTracker() useAnalytics(), which returns track, page and newPageSession

The routeKey and page props move to <AnalyticsProvider> from consentlayer/react unchanged; the one from consentlayer/next sets routeKey for you. <AnalyticsProvider> reads stored consent, boots the instance and sends a page view for each routeKey. Outside React, make the same calls once at startup:

await analytics.consent.bootstrap()
await analytics.boot()

useConsent() and useConsentController() keep their names. useConsent() now also returns update(partial). Both useAnalytics() and useConsent() work outside <AnalyticsProvider>, where they use the instance createAnalytics() created. useConsentController() still needs a provider above it, and now throws useConsentController must be used within AnalyticsProvider without one.

Calls made before boot are no longer lost. track, page, identify and group are held, up to 100 calls, and delivered in order once boot() has run.

The old package stored consent and identity under acm_consent and acm_user_identity, and announced changes through the acm:consent DOM event and the ACM_CONSENT global. ConsentLayer uses the consentlayer prefix and does not read the old names by default, so every returning visitor would see the banner again. Choose one of two options.

Migrate to the new names. Pass legacy: true to read the old keys:

createAnalytics({ providers, names: { legacy: true } })

On first read, a value stored under acm_consent or acm_user_identity is moved to consentlayer_consent or consentlayer_user_identity and the old key is removed. While legacy is on, ConsentLayer also dispatches acm:consent, sets ACM_CONSENT and ACM_openConsent, and reads and writes the __acm* window flags, so scripts that listen for the old names keep working.

Keep the old names. If the key is published, for example in your cookie policy, set the prefix instead. Every name, including the auto-tracking events, is then derived from acm exactly as before:

createAnalytics({ providers, names: { prefix: 'acm' } })
Name acm (before) consentlayer (default)
Consent storage key acm_consent consentlayer_consent
Identity storage key acm_user_identity consentlayer_user_identity
DOM event acm:consent consentlayer:consent
Consent global ACM_CONSENT CONSENTLAYER_CONSENT
Open-settings global ACM_openConsent CONSENTLAYER_openConsent

If you passed your own storage key to createLocalStorageConsentStorage(), pass it as names: { consentStorageKey: 'your_key' }, or pass the storage itself as createAnalytics({ consent: { storage } }). See Names for every option.

Automatic events are renamed whether or not legacy is on:

Before After Name GA4 receives
acm.page_session_start consentlayer.page_session_start consentlayer_page_session_start
acm.scroll_depth consentlayer.scroll_depth consentlayer_scroll_depth
acm.time_on_page consentlayer.time_on_page consentlayer_time_on_page
acm.ui_click consentlayer.ui_click consentlayer_ui_click

The GA4 provider now replaces every character other than a letter, digit or underscore with _, because GA4 rejects such names. Update reports and dashboards that filter on acm.*, or keep the old names with names: { prefix: 'acm' }.

Auto-tracking is configured with the autoTracking option instead of the <AutoTracking config> prop. Pass it autoTracking(config) from consentlayer/auto-tracking, which takes the same AutoTrackingConfig, or autoTracking() for the defaults. It still starts only once analytics consent is granted.

A config that turns on a kind, with true or its options, now turns on only the kinds it names; before, every other kind stayed on unless set to false. A config that turns nothing on, such as { scroll: false }, still keeps every other kind on.

A click’s ui_target is now the value of the data-track attribute or aria-label, and 'unknown' without either. It no longer falls back to the element’s name or text, so page content doesn’t reach your analytics. Add data-track to the elements your reports group clicks by. See Auto-tracking.

// Before: clicks in attribute mode, plus page sessions, scroll and time on page
<AutoTracking config={{ clicks: { mode: 'attribute' } }} />
// Now: name each kind you want
autoTracking: autoTracking({ clicks: { mode: 'attribute' }, pageViews: true, scroll: true, timeOnPage: true })
Before After
createGa4Adapter({ measurementId }) ga4({ measurementId })
createMetaAdapter({ pixelId, locale, serverEndpoint, fetchImpl }) meta({ pixelId, locale }); see server routes
createHotjarAdapter({ siteId, version }) hotjar({ siteId, version })
createTikTokConsentAdapter() tiktok({ pixelId }); see below
createVercelAdapter({ trackEvent }) vercel({ trackEvent })
createShopifyAnalyticsAdapter(options) Removed. Its events went to a queue that no Shopify script reads.
createShopifyCustomerPrivacyAdapter(options) shopifyCustomerPrivacy(options) from consentlayer/adapters/shopify; pass it as createAnalytics({ consent: { adapters: [...] } })

Each factory is still imported from consentlayer/providers/<name>. Four providers behave differently:

  • GA4 and Meta load after consent. The old adapters injected gtag.js and fbevents.js at boot, before the visitor chose. They now load nothing until analytics (GA4) or marketing (Meta) consent is granted. Pass loadBeforeConsent: true to download the script at boot again. GA4 still sends nothing before analytics consent: its config call runs only after consent, so no Consent Mode pings are sent while consent is denied. The Meta Pixel loads with consent revoked.
  • Vercel runs without consent. Vercel Analytics sets no cookies, so vercel() sets requiredConsent: 'none'. To keep the old analytics gate, override it: { ...vercel({ trackEvent }), requiredConsent: 'analytics' }.
  • Hotjar is deprecated. Hotjar accounts are moving to Contentsquare; use contentsquare({ tagId }) from consentlayer/providers/contentsquare once yours has moved. On analytics withdrawal, hotjar() now deletes Hotjar’s cookies and storage, and reloadOnRevoke: true reloads the page to stop recording.
  • TikTok loads the pixel. The old adapter only forwarded consent to a ttq you loaded yourself. tiktok({ pixelId }) loads the TikTok pixel after marketing consent and sends page views, events and advanced matching through it. Remove your own TikTok base code when you switch.

MetaPixelNoscript moved from consentlayer/providers/meta to consentlayer/react, so the Meta provider no longer imports React. Its props are unchanged.

PostHog and Sentry are new built-in providers. If you wrote your own PostHog plugin, compare it with posthog({ apiKey }) in Providers.

The track* helpers sent Meta’s event names to every provider. They are removed. Call track() with a GA4 event name instead; each provider translates it, so Meta still receives ViewContent, AddToCart and the other standard events. The param builders come from consentlayer/ecommerce.

Before After
trackViewContent(product) track('view_item', productParams(product))
trackAddToCart(item) track('add_to_cart', productParams(item))
trackAddToWishlist(item) track('add_to_wishlist', productParams(item))
trackInitiateCheckout(cart) track('begin_checkout', cartParams(cart)); see the cart mapping below
trackInitiateCheckout(product) (buy now) track('begin_checkout', cartParams({ lines: [product] }))
trackAddPaymentInfo({ value, currency }) track('add_payment_info', { value, currency })
trackPurchase(order) track('purchase', orderParams(order))
trackLead(params) track('lead', params); GA4 receives generate_lead
trackSearch(query, results) track('search', { search_term: query })
trackContact(method) track('contact', { method })
trackSubscribe(email) identify(undefined, { email }), then track('subscription_started')
trackCustomizeProduct(product) track('customize_product', productParams(product))
trackEvent('ViewContent', params) or another Meta name The GA4 name, such as track('view_item', params)

productParams() and orderParams() accept the same product and order shapes as before: id, variantId, title, price: { amount, currencyCode }, quantity and category, and for orders orderId, value, currency, tax, shipping, coupon and lines. handle and affiliation are no longer read. cartParams() reads lines as products, so map a Shopify cart’s merchandise first.

Mapping a Shopify cart to cartParams()
import { cartParams } from 'consentlayer/ecommerce'
analytics.track(
'begin_checkout',
cartParams({
subtotal: cart.subtotal,
lines: cart.lines.map((line) => ({
id: line.merchandise.product.id,
variantId: line.merchandise.id,
title: line.merchandise.product.title,
price: line.merchandise.price,
quantity: line.quantity,
})),
}),
)

When subtotal is omitted, value is the sum of price times quantity over the lines.

The event payloads change, so reports built on the old names or params need updating:

  • GA4 receives GA4’s own event names with an items[] array, for example view_item instead of ViewContent with product_id and variant_id.
  • Meta receives the same standard events as before, with content_ids, contents, content_type and num_items derived from items[], instead of product_id and variant_id. Content IDs are the variant ID when there is one, otherwise the product ID. search_term is sent as search_string. A Meta standard name passed as it is, such as track('Schedule'), is still sent as a standard event.
  • Every provider receives an event ID (event.id), generated unless you pass { eventId }. Meta uses it as eventID to deduplicate pixel and Conversions API events, including page views.

track() used to drop every event unless analytics consent was granted, on top of each provider’s own requirement. It no longer does: each provider’s requiredConsent alone decides, so Meta now receives events with marketing consent and no analytics consent. To restore the old rule for one event, pass { requireAnalyticsConsent: true }. page(), identify() and group() also check each provider’s consent now.

Identity changes too. identify() stores email and phone only once marketing consent is granted, and keeps them in memory until then. Without marketing consent, only the user ID is stored and shared with providers.

consentlayer/ui is removed, with CookieBanner, CookieDialog, ManageConsentTrigger and styles.css. Copy the Consent UI recipe into your app; it is built on the same useConsentController() hook the old components used. If you copied the .acm-* markup or themed the --acm-* custom properties, move those styles onto the new component.

To open the settings from outside React, call window.CONSENTLAYER_openConsent(), or keep window.ACM_openConsent() with names: { legacy: true } or names: { prefix: 'acm' }.

Three behaviors of useConsentController() and the consent API changed:

  • “Reject non-essential” denies preferences. rejectNonEssential() stores preferences: false; it used to store true. If your app reads consent.preferences, for example to set a language or theme cookie, visitors who reject now have it off.
  • “Save” stores the preferences toggle. prefs now includes preferences, and savePreferences() stores its value instead of always storing true. It starts as false until the visitor turns it on.
  • setConsent() validates its input. It rejects with a TypeError unless preferences, analytics and marketing are booleans. Previously a string such as 'false' counted as granted. Convert values read from your own cookie or form before passing them.
TypeError: [consentlayer] consent must have boolean preferences, analytics and marketing, received {"preferences":true,"analytics":"false","marketing":false}.

The Meta adapter used to post each pixel event to serverEndpoint in its own format, which your route converted with createMetaEventPayload() and sent with sendMetaCapiEvent(). Both sides are replaced:

lib/analytics.ts
createAnalytics({
providers: [meta({ pixelId })],
server: { endpoint: '/api/analytics' },
})
app/api/analytics/route.ts
import { createAnalyticsHandler } from 'consentlayer/server'
import { metaCapi } from 'consentlayer/server/meta'
export const POST = createAnalyticsHandler({
destinations: [
metaCapi({
pixelId: process.env.NEXT_PUBLIC_FACEBOOK_PIXEL_ID,
accessToken: process.env.META_TOKEN,
}),
],
})
Before After
createMetaAdapter({ serverEndpoint, fetchImpl }) createAnalytics({ server: { endpoint } })
A route that calls createMetaEventPayload() and sendMetaCapiEvent() createAnalyticsHandler({ destinations: [metaCapi(...)] })
A route that forwards browser events with sendGa4MeasurementEvent() Delete it; gtag.js already sends them. Use sendServerEvent() with ga4Server(...) for server-only events such as webhooks
apiVersion defaulting to v18.0 metaCapi({ apiVersion }), defaulting to v26.0

The browser now posts { id, name, params, consent, identity, url, only, providers }, which a route written for the old body rejects or misreads. Forwarding now covers page views, which reach the Conversions API as PageView, and every track() call, not only the Meta adapter’s. The browser forwards only while analytics or marketing consent is granted, and metaCapi() sends only events whose consent.marketing is true.

consentlayer/server no longer exports the low-level helpers: createMetaEventPayload, sendMetaCapiEvent, createMetaGraphUrl, extractMetaCookies, sanitizeMetaCustomData, sha256, assertSameOrigin, parseJsonWithLimit, parseCookieHeader, ANALYTICS_MAX_BODY, createGa4MeasurementPayload, createGa4CollectUrl and sendGa4MeasurementEvent. The handler performs the same-origin check, the body limit, the cookie parsing and the hashing itself. Hashing uses Web Crypto, so the server entry points run on the Edge runtime. See Server-side tracking.

Removed Replacement
isValidGa4MeasurementId, assertValidGa4MeasurementId None. ga4() throws for an invalid ID
isValidMetaPixelId, assertValidMetaPixelId None. meta() throws for an invalid ID
isHttpsOrRelativeUrl, assertHttpsOrRelativeUrl None. createAnalytics() throws for an invalid server.endpoint
isValidEmail, isValidPhoneE164 None. identify() drops invalid emails and phone numbers
renderMetaPixelNoscriptHtml(pixelId) <MetaPixelNoscript pixelId={pixelId} enabled /> from consentlayer/react
ConsentEngine, ConsentEngineOptions, AnalyticsManager, AnalyticsManagerOptions types Analytics, ConsentApi and AnalyticsOptions
ProviderAdapter, AnalyticsProviderContext types AnalyticsPlugin, PluginContext

The storage factories (createLocalStorageConsentStorage, createLocalStorageIdentityStorage and the memory variants) are still exported from the package root. createEnvConfig is too, but it is deprecated: pass process.env values to the providers directly, since a provider whose ID is unset turns itself off.

A provider you wrote yourself is now a plugin. Wrap it in definePlugin() from consentlayer for type checking, and rename its hooks:

Before After
ProviderAdapter AnalyticsPlugin
AnalyticsProviderContext PluginContext
init(ctx) load(ctx). Inject scripts here. It runs once, the first time consent allows it
onConsentChange(ctx) consent(ctx)
trackPage(payload, ctx) page(page, ctx). page.id is the page view’s event ID
trackEvent(event, ctx) track(event, ctx)
enabled(ctx) Removed. Leave the plugin out of providers, or express the condition as a requiredConsent function
ctx.isBooted Removed. Hooks run only after boot
event.options.eventId ?? createEventId(), metaEventIdFrom(event) event.id
event.options.sendTo event.options.only. event.options.providers[yourId] holds params meant only for your plugin
ctx.identity.external_id ctx.identity.userId. Identities stored under external_id are read as userId

The lifecycle changes in three ways:

  • setup runs once per plugin, before consent and load and whatever the consent. In the browser it runs when the instance is created, for plugins passed to createAnalytics() while enabled is on; otherwise it runs on the first lifecycle pass after boot. It must not load scripts, send requests or set cookies. Move that work to load.
  • load runs once. The old init ran each time the provider became active again after a withdrawal.
  • A plugin without requiredConsent was active before consent, but its trackEvent waited for analytics consent. requiredConsent is now required, and a plugin without it never runs: set it explicitly, to 'none' for a plugin that needs no consent.

New optional hooks are identify(identity, traits, ctx), reset(ctx), group(groupId, traits, ctx) and error(error, context, ctx). A plugin can also set loadBeforeConsent and defer.

Errors from plugin hooks reach onError under new scopes:

Before After
provider-init provider-load
provider-consent-change provider-consent
provider-track-page provider-page
provider-track-event provider-track
identity-validation Removed

provider-setup, provider-teardown and provider-script-load keep their names. The full list is in Troubleshooting.

checkPlugin() from consentlayer/testing runs a plugin through the manager in a DOM test environment, such as jsdom or happy-dom, and returns the contract violations it finds:

import { checkPlugin } from 'consentlayer/testing'
expect(await checkPlugin(myPlugin)).toEqual([])

See Writing a plugin for the full contract.