SDK reference
@nastar/consent-sdk is a framework-independent TypeScript package — no Nuxt, Vue, or NestJS dependency. It ships as an ESM module for bundlers and an auto-initializing browser bundle for plain <script> installs, both served from the CDN.
npm install @nastar/consent-sdk fails today — the name is reserved for the eventual release, not live on the registry. The CDN is the install path that works, for bundled apps as well as plain HTML. Both forms are below. Install
For a non-bundled site, the auto-init browser build:
<script defer src="https://cdn.nastar.dev/sdk/v1/nastar.min.js"
data-site-key="YOUR_PUBLIC_SITE_KEY"></script> The auto-init build reads data-site-key off its own <script> tag and exposes the client as window.Nastar. An optional data-api-base-url attribute overrides the API origin (defaults to the production API).
For a bundler, import the ESM build from the same CDN:
import { createNastarClient } from 'https://cdn.nastar.dev/sdk/v1/nastar.esm.js' It exports exactly what the npm package will: createNastarClient, plus renderBanner, contrastRatio and bestOnColor (see Other module exports) — and nothing that auto-initializes. Bundlers that won't resolve a remote URL (most of them, by default) can instead vendor the file into your repository, or load it with a plain <script type="module">. TypeScript types ship with the npm package rather than the CDN, so you'll declare the client's shape yourself until that release — the Types section below is the authoritative source.
Rolling vs. pinned URLs
Every build is published twice, and the difference matters:
| URL | What it serves | Trade-off |
|---|---|---|
/sdk/v1/nastar.min.js/sdk/v1/nastar.esm.js | The current SDK release, always. Short-cached (5 minutes). | Bug and security fixes reach your site without a redeploy — and so does any regression. This is what the dashboard's snippet and every example here uses. |
/sdk/0.4.0/nastar.min.js/sdk/0.4.0/nastar.esm.js | That exact version, forever. Immutable, cached for a year. | Nothing changes under you, and nothing is fixed under you either: you upgrade by editing the URL, so you have to watch the changelog yourself. |
v1 is the delivery contract, not the package version — it deliberately does not move when the SDK goes 0.3.x → 1.0, so a pasted snippet doesn't stop resolving on a version bump. See Compatibility for how breaking changes are versioned and announced.
Initialize
import { createNastarClient } from 'https://cdn.nastar.dev/sdk/v1/nastar.esm.js'
const client = createNastarClient({
siteKey: 'YOUR_PUBLIC_SITE_KEY',
apiBaseUrl: 'https://api.nastar.dev', // optional, defaults to production
})
await client.ready // resolves once config + stored consent are readNastarClient
| Member | Type | Behavior |
|---|---|---|
ready | Promise<void> | Resolves after the initial config fetch and stored-consent read finish, and the banner (if needed) has rendered. |
getConsent() | ConsentState | Current state synchronously. Before ready resolves, returns { status: 'unknown', categories: {}, configVersion: null }. |
hasConsent(categoryKey) | boolean | Shorthand for getConsent().categories[categoryKey] === true. Always false for an unknown category or while status is 'unknown'. |
openPreferences() | void | Opens the preferences dialog. No-op if config hasn't loaded yet. |
onConsentChange(callback) | (state: ConsentState) => () => void | Registers a listener, called on every state change (including the initial resolution). Returns an unsubscribe function. |
loadWhenConsented(categoryKey, loader) | (string, () => void) => void | Calls loader once, the moment categoryKey becomes (or already is) consented. Never calls it while unknown/withdrawn/rejected. Fires at most once per category per page load. |
trackPageView(pagePath?) | void | Fires a GA4 page_view event — no-op unless Google Analytics is enabled and loaded, matching the initial page_view the SDK fires itself. In Basic mode that means after consent only, and it stops again if consent is withdrawn. In Advanced mode GA is loaded before a decision by design, so route changes are reported in that state too, as the cookieless pings that mode exists for. Call it from your router's after-navigation hook; the initial page load is already covered. |
setLanguage(locale) | (string) => void | Re-renders the banner's text in locale, if that locale is published. Text only — consent state is never reset and a dismissed banner is never reopened, because a decision is language-independent. No-op for an unpublished locale. Most sites never need this: the SDK already follows <html lang> (see Languages), and this covers apps that switch language without touching that attribute. |
destroy() | void | Removes the rendered banner/dialog, disconnects the <html lang> observer, and detaches the cross-tab storage listener. For SPA teardown/tests. |
Other module exports
Besides createNastarClient, the package entry exports three functions that have nothing to do with consent state. They exist so that anything rendering or validating a banner — Nastar's own dashboard preview included — uses the same code visitors receive, rather than a second copy that can drift from it.
| Export | Signature | What it does |
|---|---|---|
renderBanner | (options: BannerOptions) => BannerHandle | UI only — no network, no storage, no consent recording. Mounts the banner for a given config and hands back { openPreferences, destroy }. Use it to preview a configuration; never as a substitute for createNastarClient, which is the only thing that fetches config, reads and writes stored consent, and records decisions. |
contrastRatio | (foreground: string, background: string) => number | WCAG 2.2 contrast ratio between two hex colours (#rgb or #rrggbb). Throws on a malformed colour. |
bestOnColor | (background: string) => '#000000' | '#ffffff' | The label colour to put on background. This is what keeps the primary button's label readable whatever accent an admin picks, which is why it is computed rather than configurable. |
Types
type ConsentStatus = 'unknown' | 'given' | 'withdrawn'
interface ConsentState {
status: ConsentStatus
categories: Record<string, boolean> // category key -> consented
configVersion: number | null
}
interface CategoryConfig {
key: string
label: string
description: string
necessary: boolean // true = always on, never shown as toggleable
}
interface ServiceConfig {
id: string
name: string
purpose: string
categoryKey: string
}
interface GoogleConsentModeConfig {
enabled: boolean
adStorageCategoryKey: string | null
analyticsStorageCategoryKey: string | null
adUserDataCategoryKey: string | null
adPersonalizationCategoryKey: string | null
}
interface GoogleAnalyticsConfig {
enabled: boolean
measurementId: string | null
consentMode: 'basic' | 'advanced'
categoryKey: string | null
}
type BannerMode = 'light' | 'dark'
type BannerPlacement = 'bottom' | 'center' | 'bottom-left' | 'bottom-right'
interface AppearanceSettings {
mode: BannerMode
placement: BannerPlacement
accent: string // '#rrggbb' — primary button fill and focus ring
surface: string // panel background
text: string // body text
muted: string // category descriptions, secondary text
border: string // panel and control borders
radius: number // 0-16 px
logoUrl: string | null
logoAlt: string | null
privacyPolicyUrl: string | null
cookiePolicyUrl: string | null
}
interface LocaleContent {
bannerCopy: BannerCopy
categories: Record<string, { label: string; description: string }> // by category KEY
services: Record<string, { name: string; purpose: string }> // by service ID
}
interface SiteConfig {
siteId: string
version: number
bannerCopy: BannerCopy
categories: CategoryConfig[]
services: ServiceConfig[]
googleConsentMode?: GoogleConsentModeConfig // present only when explicitly enabled
googleAnalytics?: GoogleAnalyticsConfig // present only when explicitly enabled
appearance?: AppearanceSettings // absent = today's default look
translations?: Record<string, LocaleContent> // locale -> content; excludes the default language
publishedLanguages?: string[] // includes the default, e.g. ['en','nl']
defaultLanguage?: string // e.g. 'en'
consentRelevantVersion?: string // 'sha256:<hex>'
} Every field added in 0.4.0 is optional. A config without them renders exactly as it did before, which is what lets a site pinned to an older /sdk/<version>/ path keep working untouched: an older SDK simply ignores the fields it does not know.
Behavior contract
First visit
No stored consent → status 'unknown', all categories false, banner renders.
Necessary categories
A category with necessary: true is always true in every returned state, regardless of what was passed to accept/reject/save — it is never rendered as a toggle in the preferences dialog.
When a returning visitor is asked again
Since 0.4.0 the trigger is consentRelevantVersion, a hash the backend computes over only the parts of a config a visitor actually consented to: the enabled categories, their keys and necessary flags, and which category each service belongs to. If the stored hash matches the published one, the decision stands.
So editing banner copy, colours, a logo or a translation no longer re-prompts anyone. Adding a category, switching one on or off, or moving a service to a different category still does — those change what the visitor was asked.
Three cases are deliberately conservative, because under-prompting is a consent failure while over-prompting is only an annoyance:
- A stored decision with no
consentRelevantVersionagainst a config that has one — a visitor who last decided before 0.4.0 — is re-prompted once. - An unreadable or malformed stored hash is treated as absent, so the visitor is re-prompted.
- A config carrying no
consentRelevantVersion(an older backend) falls back to the previous behaviour: any change toversionre-prompts.
Malformed or expired stored state
If localStorage holds unparseable JSON, or a shape the SDK doesn't recognize, it's treated the same as "no stored consent": status resets to 'unknown' and the banner re-renders. The SDK never throws from a storage read.
API/CDN outage — fail closed
If the config fetch fails (network error or non-2xx), the SDK never throws, never shows a banner, and getConsent() stays { status: 'unknown', categories: {}, configVersion: null } for the rest of the page's life. loadWhenConsented never fires. Essential site functionality — everything not gated behind the SDK — is unaffected; only optional integrations stay blocked.
Cross-tab sync
Consent changes in one tab propagate to other open tabs of the same site via the storage event — each tab's listeners and pending loadWhenConsented loaders re-evaluate against the new state, subject to the same config-version check above.
Storage blocked or disabled
If localStorage is unavailable (private browsing restrictions, disabled storage), writes are best-effort and reads return nothing usable — behavior degrades to "first visit" every load: fail-open on essential functionality, fail-closed on optional consent, never a thrown exception.
SSR
The SDK is a browser-only client — there is no server-side entry point. On an SSR page, only initialize it client-side (e.g. inside a mounted hook); nothing in the SDK reads window or document at module load time except the auto-init build, which requires a real <script> tag with data-site-key to run at all.
Teardown / withdrawal limitations
Withdrawing consent stops future loadWhenConsented calls for that category and updates getConsent() immediately — it does not retroactively unset cookies or undo side effects a script already caused before withdrawal. If a gated script needs cleanup on withdrawal (e.g. clearing its own cookies), that cleanup is the integrator's responsibility inside an onConsentChange handler, or by prompting a reload.
Appearance
When a site's published config carries appearance, the SDK applies it as CSS custom properties on the banner's host element. There is no per-button colour and no free-form CSS field, by design: Accept all, Reject all and Customize keep identical size, padding, font weight and minimum height, and only the fill differs. Equal prominence is structural rather than a rule that can be quietly broken.
| Custom property | From | Fallback when appearance is absent |
|---|---|---|
--nastar-surface | appearance.surface | #fff |
--nastar-text | appearance.text | #15181c |
--nastar-muted | appearance.muted | #666 |
--nastar-border | appearance.border | #e3e1dc |
--nastar-accent | appearance.accent | #0b6b57 |
--nastar-on-accent | computed — bestOnColor(accent) | #fff |
--nastar-radius | appearance.radius | 10px |
Colours are validated server-side when they are saved and again at publish, against WCAG 2.2 AA: text and muted text need 4.5:1 against the surface, the border and accent need 3:1, and the computed label colour needs 4.5:1 on the accent. A combination that fails is refused with the measured ratio and a suggested nearby value — see the API reference for the error codes.
The banner renders in a shadow root
<div data-nastar-root> appended to <body>, and its styles live inside that root instead of in document.head. Any CSS on your own site that targeted .nastar-* classes stops applying. It was never a documented extension point, and closing it is the point — style isolation is what makes the contrast and equal-prominence guarantees above hold on a real page. If you were restyling the banner, move those choices into the Look section of the Banner tab. The root is open, so tests and devtools can still reach in: document.querySelector('[data-nastar-root]').shadowRoot. Anything asserting against the banner in your own test suite needs that hop — a plain document.querySelector('.nastar-btn') now finds nothing.
Languages
A site can publish its banner in several languages. The default language stays in bannerCopy, categories and services exactly as before; every other published locale is a sibling entry in translations. The SDK resolves which one to show in this order, taking the first that is published:
- An explicit override —
setLanguage(locale), or thedata-languageattribute on the auto-init script tag. document.documentElement.lang— the<html lang>attribute.- Each entry of
navigator.languages, in the browser's own order. - The site's
defaultLanguage.
Each candidate is matched exactly first, then by base language, so nl-BE resolves to a published nl before moving on. <html lang> comes before the browser's preference deliberately: on a multilingual site the visitor already chose by navigating to /nl/, and a banner in a different language from the page it sits on is worse than one in the visitor's second language.
The SDK watches <html lang> with a MutationObserver, so a site that switches language client-side re-renders the banner text in place. Re-rendering swaps text only: it never resets consent and never reopens a dismissed banner. Consent records store category keys, never labels, so changing language cannot invalidate anything already recorded.
A locale is only publishable once it is complete — all nine banner strings, plus a label and description for every enabled category and a name and purpose for every service. A half-translated consent notice is refused at publish, naming exactly what is missing.
Good vs. bad patterns
| Bad | Good | |
|---|---|---|
| Analytics | Analytics <script> tag hardcoded in <head>, runs on every visit. | client.loadWhenConsented('analytics', () => { /* create the tag here */ }) |
| Tag manager | GTM container loaded unconditionally; consent handled only inside GTM's own opaque config. | Load the GTM loader itself behind loadWhenConsented, or push a real category-gated signal into dataLayer that GTM triggers respect. |
| Server-side tracking | Server renders a tracking pixel/script unconditionally because "the client will hide it". | Do not render it server-side at all unless the server can independently verify consent (e.g. from a forwarded, verified cookie) — the SDK has no server API. |
| CSP | script-src 'unsafe-inline' to let a gated inline script run. | Create the gated script via document.createElement('script') with a src, and allowlist the vendor's actual host in script-src — no inline-script exception needed. |
Compatibility
| SDK | Published config schema | Backend API |
|---|---|---|
| 0.1.0 | v1 (SiteConfig as documented above) | v1 (/v1/public/*) |
| 0.2.0 | v1 (SiteConfig as documented above, plus optional googleConsentMode) | v1 (/v1/public/*) |
| 0.3.0 | v1 (SiteConfig + optional googleAnalytics) | v1 (/v1/public/*) |
| 0.3.1 | v1 (unchanged) | v1 (/v1/public/*) |
| 0.4.0 | v1 (SiteConfig + optional appearance, translations, publishedLanguages, defaultLanguage, consentRelevantVersion) | v1 (/v1/public/*) |
0.4.0 is additive at the config level — every new field is optional and an older SDK ignores all of them — so it is a minor bump. It does carry two behavioural changes on the SDK side, both documented above and on the changelog: the banner moves into a shadow root, which stops any site CSS that targeted .nastar-* from applying, and re-prompting is driven by consentRelevantVersion instead of version, so copy-only publishes stop resetting consent.
0.2.0 is backward compatible with 0.1.0's config shape — googleConsentMode is optional and additive, not a breaking change, so this was a minor version bump, not a major one. 0.3.0 is likewise backward compatible with 0.2.0 — googleAnalytics is optional and additive, another minor bump, not a major one. Earlier versions: there is no migration history for 0.1.0 or before. Breaking changes to the SDK's public API or the published config shape will bump the SDK's major version and be documented on the changelog once they happen.
Google Analytics lifecycle
When a site enables Google Analytics from the dashboard, the SDK owns GA4's entire lifecycle — no separate gtag.js script or manual Consent Mode wiring belongs on the customer's site.
Basic mode waits for the mapped category to be consented before injecting gtag.js at all — zero requests to Google before that point. Advanced mode injects it immediately once config resolves, but always after the existing gtag('consent', 'default', ...) push for this page load, so Google's client always receives an explicit state first.
The SDK fires the initial page_view itself the moment GA activates — that covers a traditional multi-page site with zero extra code. trackPageView() is for subsequent SPA navigations only; GA4's own automatic history-based page-view detection is disabled so there is exactly one page-view mechanism, never two racing to double-count. Both are gated the same way, on GA being loaded: in Basic mode that is after consent (and stops on withdrawal), in Advanced mode it includes the pre-decision state, where Google treats the hits as cookieless pings. This is Google's public, uncertified gtag API — not a Google-certified integration.
// plugins/nastar-page-views.client.ts
export default defineNuxtPlugin(() => {
const router = useRouter()
router.afterEach((to) => {
window.Nastar?.trackPageView(to.fullPath)
})
}) That snippet in its full context, including where the <script> tag goes and how to keep it out of dev builds, is in the Nuxt guide.