Writing an adapter
Load a script, subscribe to an event, report what the platform says.
An adapter is a connect function. defineAdapter turns it into a typed
factory:
import { defineAdapter } from "@libconsent/core";
export const acmeCmp = defineAdapter<{ siteId: string }>({
id: "acme-cmp",
name: "Acme CMP",
async connect({ siteId }, ctx) {
await ctx.loadScript(`https://cdn.acme-cmp.com/${siteId}.js`);
const api = await ctx.waitFor(() => window.AcmeCmp);
const publish = () =>
ctx.update({
ready: true,
hasDecision: api.hasAnswered(),
categories: {
essential: true,
analytics: api.allows("stats"),
marketing: api.allows("ads"),
},
});
ctx.on("acme:consent", publish);
publish();
return {
open: () => api.showBanner(),
openSettings: () => api.showSettings(),
acceptAll: () => api.acceptAll(),
rejectAll: () => api.rejectAll(),
};
},
});<ConsentProvider adapter={acmeCmp({ siteId: "abc" })}>The context
Everything the context gives you is tied to the connection: listeners are removed and pending waits reject when the provider unmounts or the adapter changes, so most adapters need no teardown code of their own.
| Method | |
|---|---|
update(patch) | Report what the platform says. |
getState() | What the core currently believes. |
loadScript(src, attrs) | Inject a <script> once per URL, however often connect runs. |
waitFor(getter, opts) | Resolve once the getter returns something — for globals that appear late. |
on(type, handler, target) | addEventListener that cleans itself up. |
onDisconnect(fn) | Anything else that needs unwinding. |
signal | An AbortSignal for your own async work. |
warn(message) | Namespaced, and silent in production. |
What to report
ctx.update({
ready: true, // you have real values — nothing is granted before this
hasDecision: false, // the user chose, rather than you reporting defaults
view: "hidden", // see below
categories: { essential: true, analytics: false },
services: { "Google Analytics": true },
});Service keys are normalised on the way in, so push whatever spelling the
platform uses — "Google Analytics", "google_analytics" and "C0002" all
become lookup keys as-is.
Fields you leave out keep their current value, but categories and services
are replaced rather than merged. Report the whole picture each time: an
override you stop mentioning has been withdrawn, and merging would leave a
service switched off forever after the user pressed "accept all".
Identical reports are dropped, so a platform that re-announces unchanged consent on every interaction doesn't cause a render each time.
view
view is which consent UI libconsent should render. A platform that draws its
own banner should leave it at "hidden", which is what lets <ConsentBanner>
stay mounted without rendering anything.
Set it to "banner" or "settings" only if your adapter expects the app to
render the UI, as the built-in one does.
Commands
Return the actions your platform supports. Everything is optional; supports
reports what you returned, and calling something you didn't warns in
development instead of throwing.
return {
open,
openSettings,
close,
acceptAll,
rejectAll,
reset,
setConsent, // only if the platform lets you write an exact state
destroy, // anything beyond the context helpers
};Declaring setConsent enables useConsentForm(), and with it the shadcn
banner. Add supportsServices: true if setConsent also honours
per-service overrides; without it the dialog offers categories only, rather than
switches that would do nothing.
Two flags on the definition
defineAdapter({
id: "acme-cmp",
name: "Acme CMP",
handlesConsentMode: true, // the platform writes Google Consent Mode itself
supportsServices: true, // setConsent honours per-service overrides
connect,
});Idempotence
connect can run more than once: React Strict Mode double-invokes effects in
development, and a remounted provider reconnects. loadScript dedupes by URL
for you. If you patch a global, restore the old value in onDisconnect, as the
OneTrust adapter does for OptanonWrapper.