Skip to content

JavaScript and Astro

This guide sets up ConsentLayer in code that uses no framework binding: plain JavaScript or TypeScript bundled by any bundler, or an Astro site. You create the instance, then a consent controller, which does what the React provider and the Vue plugin do for you: it reads the stored choice, boots the providers, holds your banner’s state and sends page views.

It assumes you have installed consentlayer and the SDKs of the providers you use.

src/analytics.ts
import { createAnalytics, createConsentController } from 'consentlayer'
import { ga4 } from 'consentlayer/providers/ga4'
import { autoTracking } from 'consentlayer/auto-tracking'
export const analytics = createAnalytics({
providers: [ga4({ measurementId: import.meta.env.VITE_GA4_MEASUREMENT_ID })],
autoTracking: autoTracking(),
})
export const controller = createConsentController(analytics)
controller.start()
controller.setRoute(location.pathname + location.search)

start() performs the steps listed in React: it calls analytics.consent.bootstrap(), schedules analytics.boot(), and sends a page view to each provider once it is active. It returns a function that stops it. setRoute(key) sends a page view to each provider when the key changes, and starts a new page session. Call it again after every client-side navigation; on a site where every navigation loads a new page, the call above is enough.

Option Type Default Description
boot { mode?, timeoutMs? } { mode: 'immediate' } When providers start. See Boot timing.
autoShowBanner boolean true Whether bannerVisible becomes true when there is no stored choice or the stored choice is stale.
openConsentGlobalKey string 'CONSENTLAYER_openConsent' The window function that opens the settings dialog, set by start().

The controller holds the state of a banner and a settings dialog. getSnapshot() returns it, subscribe(listener) calls listener after each change, and actions holds the functions that change it. The API reference lists them.

<section id="consent-banner" hidden>
<p>We use cookies to run this site and, with your consent, for analytics.</p>
<button type="button" data-action="rejectNonEssential">Reject non-essential</button>
<button type="button" data-action="openDialog">Customize</button>
<button type="button" data-action="acceptAll">Accept all</button>
</section>
<dialog id="consent-dialog">
<form method="dialog">
<div id="consent-categories"></div>
<button type="button" data-action="closeDialog">Close</button>
<button type="button" data-action="savePreferences">Save</button>
</form>
</dialog>
<button type="button" id="cookie-settings" data-action="openDialog" hidden>Cookie settings</button>
src/consent-banner.ts
import { controller } from './analytics'
const banner = document.getElementById('consent-banner')!
const dialog = document.getElementById('consent-dialog') as HTMLDialogElement
const settings = document.getElementById('cookie-settings')!
const list = document.getElementById('consent-categories')!
type Action = 'acceptAll' | 'rejectNonEssential' | 'savePreferences' | 'openDialog' | 'closeDialog'
function renderCategories() {
const { categories, prefs } = controller.getSnapshot()
list.replaceChildren(
...categories.map((category) => {
const label = document.createElement('label')
const input = document.createElement('input')
input.type = 'checkbox'
input.checked = prefs[category]
input.onchange = () =>
controller.actions.setPrefs({ ...controller.getSnapshot().prefs, [category]: input.checked })
label.append(input, ` ${category}`)
return label
}),
)
}
function render() {
const { ready, bannerVisible, dialogVisible } = controller.getSnapshot()
banner.hidden = !ready || !bannerVisible || dialogVisible
settings.hidden = !ready || bannerVisible
if (dialogVisible && !dialog.open) {
renderCategories()
dialog.showModal()
}
if (!dialogVisible && dialog.open) dialog.close()
}
document.addEventListener('click', (event) => {
const action = (event.target as HTMLElement).closest<HTMLElement>('[data-action]')?.dataset.action
if (action) void controller.actions[action as Action]()
})
dialog.addEventListener('close', () => controller.actions.closeDialog())
controller.subscribe(render)
render()

The checkboxes are built when the dialog opens, so toggling one keeps the keyboard focus. Give each category a visible label and description, as the recipe does. Closing the dialog with the Escape key fires its close event, which keeps the controller in sync.

Import the instance wherever you track:

import { analytics } from './analytics'
document.querySelector('#sign-up')?.addEventListener('click', () => {
void analytics.track('sign_up', { method: 'email' })
})

analytics.consent.getConsent() returns the current consent, null before a choice, and analytics.consent.subscribe(listener) reports each change. Events and identity lists the standard events.

There is no error boundary without a framework, so pass the errors you catch to analytics.captureError():

try {
await checkout()
} catch (error) {
void analytics.captureError(error, { level: 'error', tags: { area: 'checkout' } })
}

captureError delivers errors to the providers that implement error reporting, Sentry and PostHog, while their consent requirement is met. An error without a source is reported with source: 'manual'. Unless you set errors: { globalHandlers: false }, ConsentLayer also reports the window’s error and unhandledrejection events after boot. errors: false disables both. Providers covers the details.

In an Astro site, put the banner markup and the scripts in a component that your layout renders on every page. Astro bundles each <script> as a module and runs it once, so the instance and the controller are created once. Astro exposes environment variables to the browser only with the PUBLIC_ prefix, so read import.meta.env.PUBLIC_GA4_MEASUREMENT_ID in src/analytics.ts.

With <ClientRouter /> from astro:transitions, navigations no longer load a new page. Report each one to the controller from the astro:page-load event, and keep the banner across navigations with transition:persist:

src/components/ConsentLayer.astro
<div transition:persist>
<!-- the banner, dialog and settings button from step 2 -->
</div>
<script>
import { controller } from '../analytics'
import '../consent-banner'
document.addEventListener('astro:page-load', () => {
controller.setRoute(location.pathname + location.search)
})
</script>

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 <ClientRouter />, the event never fires and the setRoute() call in src/analytics.ts covers each page load.