Consent Adapters
What you are building
A consent adapter is a small class that bridges a concrete CMP (OneTrust, CookieYes, an in-house solution) and Laioutr's consent store. The store calls init() to load the CMP, asks getConsentState() for the current categories, subscribes to onConsentChange() for live updates, and forwards user actions like "open the banner" through showConsentOverlay().
If your CMP is already covered by @laioutr-app/cookiebot or @laioutr-app/ccm19, use those instead. Build your own only when no existing app fits.
A consent adapter app is a normal Laioutr app (a Nuxt module that exposes its options on runtimeConfig.public and registers a client plugin) plus two adapter-specific pieces:
- An adapter class that implements
ConsentAdapterfrom#frontend/consent. - A client plugin that instantiates the adapter and registers it with
useConsentStore()viaregisterAdapter+activateAdapter.
For the module skeleton, options handling, and how runtimeConfig.public flows into your plugin, scaffold from the App Starter and follow App Configuration. This guide focuses on the consent-specific contract and walks through one worked example against a fictional CMP API.
The ConsentAdapter contract
The contract is exported from @laioutr-core/frontend-core and re-exported under the #frontend/consent alias:
import type { ConsentAdapter, ConsentManagementState } from '#frontend/consent';
export interface ConsentAdapter {
readonly name: string;
readonly isActive: boolean;
init(): Promise<void> | void;
getConsentState(): Promise<Partial<ConsentManagementState>> | Partial<ConsentManagementState>;
showConsentOverlay(): Promise<void> | void;
renewConsent(): Promise<void> | void;
hasCategoryConsent(category: keyof ConsentManagementState): boolean;
onConsentChange(callback: (consent: Partial<ConsentManagementState>) => void): void;
destroy?(): Promise<void> | void;
}
'cookiebot', 'ccm19', 'onetrust'). The store uses it as the key in registerAdapter / activateAdapter.true after init() finishes successfully. Useful for diagnostics; the store does not depend on it.useHead), wires up cookie or event listeners, and prepares the adapter to report consent. Throw if required configuration is missing; the store will catch the error and deactivate.state). Sync if you can read from a cookie; async only when you genuinely need to wait.getConsentState().state.A Partial<ConsentManagementState> is enough: the store merges it into the existing state, so omitted keys keep their previous value.
Registering the adapter
Once the adapter class exists, the client plugin in your app instantiates it, calls store.registerAdapter(adapter), then store.activateAdapter(adapter.name). registerAdapter is idempotent; activateAdapter deactivates any previously active adapter first, so the active provider is always exactly one. Activation triggers init() and the initial getConsentState() read inside the store, so the host app does nothing beyond installing your module.
Worked example
The fictional CMP ConsentKit is the example for the rest of the guide. Imagine its API looks like this:
declare global {
interface Window {
ConsentKit?: ConsentKitGlobal;
}
}
interface ConsentKitGlobal {
getConsent(): ConsentKitState | null;
openBanner(): void;
openPreferences(): void;
on(event: 'consent-change', handler: (state: ConsentKitState) => void): () => void;
}
interface ConsentKitState {
essential: boolean;
functional: boolean;
analytics: boolean;
ads: boolean;
}
The widget is loaded with a script tag, exposes a window.ConsentKit global once ready, fires a consentkit:ready event when that global becomes available, and lets you subscribe to consent changes via ConsentKit.on('consent-change', ...). A real CMP will look broadly like this; the moving parts (script injection, late init, mapping, cleanup) are the same shape regardless of the provider.
Here is the full adapter:
import { useHead } from 'nuxt/app';
import type { ConsentAdapter, ConsentManagementState } from '#frontend/consent';
interface ConsentKitConfig {
apiKey: string;
region?: string;
}
export class ConsentKitAdapter implements ConsentAdapter {
readonly name = 'consentkit';
private _isActive = false;
private _callbacks: Array<(c: Partial<ConsentManagementState>) => void> = [];
private _unsubscribe: (() => void) | null = null;
constructor(private _config: ConsentKitConfig) {}
get isActive() { return this._isActive; }
async init() {
if (!this._config.apiKey) throw new Error('ConsentKit: apiKey is required');
const params = new URLSearchParams({ key: this._config.apiKey });
if (this._config.region) params.set('region', this._config.region);
useHead({
script: [{ id: 'consentkit', src: `https://cdn.consentkit.example/widget.js?${params}`, async: true }],
});
if (import.meta.client) {
// The widget may load before or after this plugin runs; handle both.
const subscribe = () => {
this._unsubscribe = window.ConsentKit!.on('consent-change', (state) => {
this._notify(this.mapConsent(state));
});
const initial = window.ConsentKit!.getConsent();
if (initial) this._notify(this.mapConsent(initial));
};
if (window.ConsentKit) subscribe();
else window.addEventListener('consentkit:ready', subscribe, { once: true });
}
this._isActive = true;
}
getConsentState(): Partial<ConsentManagementState> {
if (import.meta.client && window.ConsentKit) {
const current = window.ConsentKit.getConsent();
if (current) return this.mapConsent(current);
}
return { necessary: true }; // SSR or pre-load: only essential is safe by default.
}
hasCategoryConsent(category: keyof ConsentManagementState): boolean {
return this.getConsentState()[category] ?? false;
}
onConsentChange(callback: (c: Partial<ConsentManagementState>) => void) {
this._callbacks.push(callback);
}
async showConsentOverlay() {
if (import.meta.client) window.ConsentKit?.openBanner();
}
async renewConsent() {
if (import.meta.client) window.ConsentKit?.openPreferences();
}
async destroy() {
this._unsubscribe?.();
this._unsubscribe = null;
this._callbacks = [];
this._isActive = false;
}
// The contract boundary: ConsentKit's category names become Laioutr's.
private mapConsent(c: ConsentKitState): Partial<ConsentManagementState> {
return { necessary: c.essential, functional: c.functional, statistics: c.analytics, marketing: c.ads };
}
private _notify(consent: Partial<ConsentManagementState>) {
for (const cb of this._callbacks) cb(consent);
}
}
Patterns worth stealing
Even if your CMP looks nothing like ConsentKit, the same handful of moves apply:
- Inject the CMP script through
useHeadso it gets the same SSR/hydration handling as any other Nuxt-managed tag. - Validate required configuration in
init()and throw on missing values. The store catches the error, logs it, and deactivates the adapter. Treat this as the right way to fail loudly. - Handle both load orderings. If the CMP's global is already on
windowwhen your plugin runs, subscribe immediately. Otherwise wait for the CMP's "ready" event. Either case must end with you holding a subscription. - Keep all category-name translation in one private method. The contract boundary belongs in one place, not sprinkled across
init(),getConsentState(), and the change handler. - Return
{ necessary: true }fromgetConsentState()when you cannot reconstruct consent on the server (or before the script has loaded). The client overwrites it once the CMP reports in, and consumers see safe defaults until then. - Save every subscription handle (the function returned by
on(...), theaddEventListenerreference) and release them indestroy(). Without this, deactivating the adapter (or hot-reloading in dev) leaks handlers.
If your CMP fires its consent events synchronously during its own init script (before any client plugin can attach), the standard fix is to inject an inline bootstrap script via useHead with tagPriority: 1. The bootstrap parses before the CMP and accumulates the early event burst into a window.__* global that getConsentState() reads later.
Notes on SSR, late init, and cleanup
A few constraints are easy to miss:
useHeadanduseCookiework on both the server and the client. Call them unconditionally insideinit(). Onlywindow,document, andaddEventListenerneed animport.meta.clientguard.- The store calls
init()first, thengetConsentState(), then registers its own callback throughonConsentChange(). The order matters when you reason about which payloads arrive at the store before activation completes (none) versus after (every subsequent_notifycall). - If your CMP exposes consent through a server-readable cookie,
getConsentState()can read it on SSR and the first byte renders with the correct state. If it does not, return the denied baseline and let the client correct it. destroy()only runs when the store deactivates the adapter (an explicitdeactivateAdapter()call or a swap to a different adapter viaactivateAdapter()). It does not run on Nuxt page navigation. Adapters that need per-route cleanup must arrange that themselves.
Once your adapter is active, useConsentStore().hasCategoryConsent('statistics') works in every consumer (your code, the tracking store, the GTM app) without anyone knowing which CMP you wired in.
Related
- App Starter. Scaffold the Nuxt module, runtime, and plugin skeleton your adapter plugs into.
- App Configuration. How
runtimeConfig, options, and per-app keys flow into your plugin. - Consent Management feature overview. The consumer-facing side of the same store.
- Cookiebot app. Reference implementation for cookie-based CMPs.
- CCM19 app. Reference implementation for event-based CMPs with custom purposes.
Coding Standards
Conventions and quality guidelines for developing Laioutr apps. Use these standards to keep app code consistent, maintainable, and aligned with the Laioutr ecosystem.
Implementation Overview
What a connector app needs to implement for Laioutr and Laioutr UI compatibility, and what existing connectors already provide.