Consent Adapters
What you are building
A consent adapter is a small object that bridges a concrete CMP (OneTrust, CookieYes, an in-house solution) and Laioutr's consent store. The store calls setup() to load the CMP and to receive the visitor's verdict — at once, and again on every change — and forwards user actions like "open the banner" through openConsentUi().
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 factory — a function that takes your app's config and returns an object satisfying
ConsentAdapterfrom#frontend/consent. - A client plugin that builds the adapter and installs it on the store with
useConsentStore().setAdapter(adapter).
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 { ConsentState } from '#frontend/consent';
/** How an adapter hands the store a verdict. Call it as often as the CMP reports one. */
export type ConsentReport = (consent: Partial<ConsentState>) => void;
/** Which of the CMP's own dialogs to open. */
export type ConsentUiView = 'banner' | 'preferences';
export interface ConsentAdapter {
readonly name: string;
setup(report: ConsentReport): void | (() => void);
openConsentUi(view: ConsentUiView): Promise<void> | void;
hasDecision?(): boolean;
}
'cookiebot', 'ccm19', 'onetrust'). Shown in the store's console warnings when the adapter is replaced or fails to start.useHead and useCookie are available -- do not make this method async or await anything before it returns. Return a cleanup function to run when the store drops the adapter, or nothing if there is none to run. Throw when required configuration is missing; the store catches the error, logs it, and drops the adapter.'banner' for the main consent prompt, 'preferences' for the granular settings panel. Some CMPs use the same UI for both; that is fine.hasDecision() as undefined to consumers, meaning "no decision signal available", never "declined".A Partial<ConsentState> passed to report is enough: the store merges it into the existing state, so omitted keys keep their previous value.
Registering the adapter
Once the adapter factory exists, the client plugin in your app builds it and hands it to the store with one call:
useConsentStore().setAdapter(createConsentKitAdapter(config));
setAdapter runs setup() immediately and returns a handle that drops the adapter -- call it, or install a different adapter, to stop it. A project should install exactly one CMP app; if a second setAdapter call arrives while one is already active, the store logs a console warning and replaces the first adapter with the second rather than running both.
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, ConsentState } from '#frontend/consent';
interface ConsentKitConfig {
apiKey: string;
region?: string;
}
// The contract boundary: ConsentKit's own vocabulary becomes Laioutr's purposes. ConsentKit has
// one `ads` bucket, so both ad purposes come from it; a CMP that separates them reports them
// separately, and that is the whole point of translating here rather than downstream.
const mapConsent = (state: ConsentKitState): Partial<ConsentState> => ({
necessary: state.essential,
functional: state.functional,
analytics: state.analytics,
advertising: state.ads,
personalization: state.ads,
});
export const createConsentKitAdapter = (config: ConsentKitConfig) =>
({
name: 'consentkit',
setup(report) {
if (!config.apiKey) {
throw new Error('ConsentKit: apiKey is required');
}
const params = new URLSearchParams({ key: config.apiKey });
if (config.region) params.set('region', config.region);
useHead({
script: [{ id: 'consentkit', src: `https://cdn.consentkit.example/widget.js?${params}`, async: true }],
});
if (!import.meta.client) {
return undefined;
}
let unsubscribe: (() => void) | undefined;
// The widget may load before or after this plugin runs; handle both.
const subscribe = () => {
unsubscribe = window.ConsentKit!.on('consent-change', (state) => report(mapConsent(state)));
const initial = window.ConsentKit!.getConsent();
if (initial) report(mapConsent(initial));
};
if (window.ConsentKit) {
subscribe();
} else {
window.addEventListener('consentkit:ready', subscribe, { once: true });
}
return () => {
unsubscribe?.();
window.removeEventListener('consentkit:ready', subscribe);
};
},
openConsentUi(view) {
if (!import.meta.client) {
return;
}
if (view === 'preferences') {
window.ConsentKit?.openPreferences();
} else {
window.ConsentKit?.openBanner();
}
},
hasDecision() {
if (!import.meta.client) {
return false;
}
return window.ConsentKit?.getConsent() != null;
},
}) satisfies ConsentAdapter;
Three things about this shape are load-bearing, not style:
satisfies ConsentAdapter, not: ConsentAdapter. A return-type annotation widens the object to the interface, so an optional member likehasDecisionbecomes optional on the value too.satisfieschecks the literal against the contract without discarding its inferred type, so callers who read the returned object still seehasDecisionas present.setupis synchronous.useHead()anduseCookie()need the active Nuxt instance, which is gone once execution resumes after anawait— sosetupis neverasync, and nothing inside it is awaited before it returns. If the CMP's own SDK is asynchronous, kick it off here and callreport()from its callback instead of awaiting it.setup's early exit isreturn undefined;, not a barereturn;. The repository'sconsistent-returnlint rule is per function: every return within one function must either specify a value or none may.setuphas a path that returns a cleanup function, so its other path must return a value too — hencereturn undefined;.openConsentUinever returns a value on any path, so its barereturn;a few lines below is correct as written.
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
setup()and throw on missing values. The store catches the error, logs it, and drops 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 vocabulary translation in one function. The contract boundary belongs in one place, not sprinkled across
setup(), the change handler, andhasDecision(). - Report the visitor's initial state synchronously inside
setup()when you can reconstruct it (typically from a server-readable cookie) — the first SSR render then reflects it with no flash. When you cannot, report nothing on the server; consumers see the denied baseline until the client reports in. - Save every subscription handle (the function returned by
on(...), theaddEventListenerreference) and release them in the functionsetup()returns. Without this, replacing 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 your event listener reads before calling report().
Notes on SSR, synchronous setup, and cleanup
A few constraints are easy to miss:
useHeadanduseCookiework on both the server and the client. Call them unconditionally insidesetup(). Onlywindow,document, andaddEventListenerneed animport.meta.clientguard.setup()runs synchronously inside the plugin that installs the adapter. Never make itasync, and never await anything before it returns — doing so loses the active Nuxt instanceuseHead()anduseCookie()need.- If your CMP exposes consent through a server-readable cookie, read and
report()it synchronously insidesetup(), and the first byte renders with the correct state. If it does not, report nothing on the server and let the client correct it once the CMP loads. - The cleanup function
setup()returns runs when the adapter is replaced: either the stop handlesetAdapterreturned is called, or a latersetAdaptercall installs a different adapter. It does not run on Nuxt page navigation. Adapters that need per-route cleanup must arrange that themselves.
Once your adapter is active, useConsentStore().hasPurposeConsent('analytics') works in every consumer (your code, the analytics bus, 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.
Consuming Query Fields
How blocks and sections read interactive state from a resolved query field and update the URL when the user changes filters, sorting, or pagination.