c15t
Version:
Headless JavaScript consent management platform for cookie banners, privacy preferences, consent storage, and script gating.
357 lines (265 loc) âĸ 14.8 kB
Markdown
---
title: Script Loader
description: Gate third-party scripts behind consent â load Google Analytics,
Meta Pixel, and other tracking scripts only when users grant permission.
group: frameworks
---
The script loader manages third-party JavaScript based on consent state. You declare scripts in your provider's `scripts` option, and c15t decides when each script should load, stay loaded, unload, or receive a consent update.
Use it for analytics, pixels, tag managers, product analytics, and other vendor snippets that should not run until the right consent condition is satisfied. Prebuilt helpers live in [`@c15t/scripts`](/docs/integrations/overview); custom scripts can be declared directly when the vendor is specific to your app.
<PackageCommandTabs mode="install" command="@c15t/scripts" />
> âšī¸ **Info:**
> Start with the integrations overview before writing your own script. Built-in helpers encode vendor boot order, consent updates, and common defaults so you do not have to.
>
> đ **Note:**
> If you need a vendor c15t does not ship yet, see the custom integration guide. It explains when a one-off Script is enough and when to build a reusable manifest-backed helper.
>
> âšī¸ **Info:**
> The script loader handles JavaScript tags and callback lifecycles. For iframe-only embeds, use the iframe blocking pattern. For UI components such as maps or video players, combine consent state with a component-level placeholder or a dedicated renderable integration.
## Basic Usage
Pass an array of `Script` objects to the runtime options:
```ts
import { getOrCreateConsentRuntime } from 'c15t';
import { metaPixel } from '@c15t/scripts/meta-pixel';
const { consentStore } = getOrCreateConsentRuntime({
mode: 'hosted',
backendURL: 'https://your-instance.c15t.dev',
scripts: [
metaPixel({ pixelId: '123456' }),
{
id: 'custom-analytics',
src: 'https://cdn.example.com/analytics.js',
category: 'measurement',
},
],
});
```
## Mental Model
Every script you register has the same lifecycle. c15t evaluates each script against the current consent state, then drives it through a small number of states:
1. **Pending** â registered but waiting for consent. Nothing is in the DOM yet.
2. **Loaded** â consent matched, c15t injected the script (or ran callbacks for callback-only scripts).
3. **Updated** â already loaded, consent state changed, `onConsentChange` ran so the SDK can react.
4. **Unloaded** â consent was revoked. c15t removed the script element unless you opted into persistence.
Four lifecycle callbacks let you hook into transitions: `onBeforeLoad`, `onLoad`, `onConsentChange`, and `onError`. Two flags â [`alwaysLoad`](#always-load) and [`persistAfterConsentRevoked`](#persist-after-revocation) â change how c15t treats consent boundaries. Everything else (DOM placement, ad-block evasion, dynamic management) is a refinement on top of this core model.
## Choose the Right Approach
Most projects mix more than one style. Pick the smallest one that keeps consent behavior obvious:
|Style|Use when|
|--|--|
|**Built-in helper** from `@c15t/scripts`|c15t already ships the vendor. See the [integrations overview](/docs/integrations/overview).|
|**Plain `Script`**|One-off app code with simple load and callback behavior.|
|**Callback-only `Script`**|Another package already loaded the SDK; c15t only synchronizes consent.|
|**Manifest-backed helper**|Reusable vendor integration with structured setup phases, queues, stubs, or a vendor consent API.|
|**Iframe / renderable integration**|Vendor exposes an iframe or React component, not just a `<script>` tag.|
## Script Types
### Standard Scripts
Standard scripts load an external JavaScript file via a `<script>` tag. This is the default for most analytics and pixel SDKs:
```tsx
{
id: 'analytics',
src: 'https://cdn.example.com/analytics.js',
category: 'measurement',
}
```
### Inline Scripts
Inline scripts execute JavaScript from `textContent` instead of loading a URL. Use these sparingly; a manifest-backed helper is usually better for reusable vendor code.
```tsx
{
id: 'gtag-config',
textContent: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXX');
`,
category: 'measurement',
}
```
### Callback-Only Scripts
Callback-only scripts do not inject a script tag. They run lifecycle callbacks when consent allows them to. Use this when another package has already loaded the SDK and c15t only needs to drive consent:
```tsx
{
id: 'posthog-consent',
callbackOnly: true,
category: 'measurement',
onLoad: ({ hasConsent }) => {
if (hasConsent) {
posthog.opt_in_capturing();
}
},
onConsentChange: ({ hasConsent }) => {
if (hasConsent) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
},
}
```
### Manifest-Backed Helpers
Built-in integrations in `@c15t/scripts` are manifest-backed. A manifest describes vendor setup as structured phases, then c15t compiles it into a `Script`. Manifests keep queue stubs, script URLs, consent signaling, and post-load work consistent across apps and they are safe to ship from a server.
Use a manifest-backed helper when:
* the integration should be reused across projects,
* the vendor snippet has ordered setup steps,
* the vendor exposes a consent API,
* or you plan to contribute the integration back to c15t.
Read the [custom integration guide](/docs/integrations/building-integrations) for the manifest contract, phases, and testing checklist.
### Iframe And Renderable Integrations
Some vendors are not just script tags. YouTube embeds, maps, calendars, and checkout widgets often need a visible component, a placeholder, or an iframe.
* For iframe-only embeds, gate the iframe `src` with the [iframe blocking](/docs/frameworks/react/iframe-blocking) pattern instead of loading a script just to hide an iframe.
* For SDK-backed UI, use the script loader for the shared SDK and render the component only when consent and SDK readiness agree.
* Use `YouTubeEmbed` for the iframe-only YouTube candidate and `GoogleMap` for the callback-based SDK candidate.
* Use `useConsentScript()` when building custom wrappers. It registers scripts through the consent store, follows `loadedScripts`, and returns a promise-shaped readiness contract for callback-based SDKs.
## Lifecycle Callbacks
Every script supports four callbacks. Each receives a `ScriptCallbackInfo` payload (id, element, hasConsent, consents):
* `onBeforeLoad` â runs before the script tag is injected. Create globals, queues, or vendor stubs here.
* `onLoad` â runs after the browser loads the script. Call vendor `init()` APIs here.
* `onConsentChange` â runs for loaded scripts when consent changes. Forward the new consent state to the vendor SDK.
* `onError` â runs when the script fails to load. Record diagnostics or render a fallback.
```tsx
{
id: 'analytics',
src: 'https://analytics.example.com/v2.js',
category: 'measurement',
onBeforeLoad: ({ id }) => {
window.analyticsQueue = window.analyticsQueue || [];
},
onLoad: () => {
window.analytics.init('my-key');
},
onError: ({ error }) => {
console.error('Failed to load analytics:', error);
},
onConsentChange: ({ hasConsent }) => {
window.analytics.setConsent(hasConsent);
},
}
```
## Consent Conditions
The `category` field accepts a `HasCondition`. It can be a single consent category or a logical expression:
```tsx
// Simple: requires measurement consent
{ category: 'measurement' }
// AND: requires both measurement and marketing
{ category: { and: ['measurement', 'marketing'] } }
// OR: requires either measurement or marketing
{ category: { or: ['measurement', 'marketing'] } }
```
Consent categories use the same names as the rest of c15t (`necessary`, `functionality`, `experience`, `measurement`, `marketing`).
## Persistence Options
### Always Load
`alwaysLoad` loads the script regardless of whether its category is currently granted. Use it only when the vendor must be present early **and** has a reliable consent API of its own â Google Tag Manager with Consent Mode is the canonical example.
```tsx
{
id: 'google-tag-manager',
src: 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXX',
category: 'measurement',
alwaysLoad: true,
}
```
When `alwaysLoad` is on, `onConsentChange` becomes mandatory: it is how the loaded SDK learns about every transition.
> â ī¸ **Warning:**
> alwaysLoad shifts compliance responsibility to the vendor integration. Make sure the script receives denied-by-default consent signals before it can track.
### Persist After Revocation
`persistAfterConsentRevoked` keeps a script in the page after consent is revoked instead of unloading it. Use it only when the vendor exposes a runtime consent toggle â otherwise unloading is safer because removing the element guarantees the SDK stops.
```tsx
{
id: 'error-tracking',
src: 'https://errors.example.com/track.js',
category: 'measurement',
persistAfterConsentRevoked: true,
onConsentChange: ({ hasConsent }) => {
window.ErrorTracker.setConsent(hasConsent);
},
}
```
As with `alwaysLoad`, `onConsentChange` is how the persisted SDK learns about consent updates.
### `alwaysLoad` vs `persistAfterConsentRevoked`
These two flags answer different questions. Use this table to keep them straight:
|Question|`alwaysLoad`|`persistAfterConsentRevoked`|
|--|--|--|
|Loads before consent is granted?|Yes|No (waits for consent like a normal script)|
|Stays loaded after consent is revoked?|Yes|Yes|
|Requires a vendor consent API?|Yes|Yes|
## DOM Placement
Control where the script is injected and whether the element id is anonymized:
```tsx
{
id: 'widget',
src: 'https://widget.example.com/embed.js',
category: 'experience',
target: 'body', // 'head' (default) or 'body'
anonymizeId: true, // default: true, hides the c15t script id from ad blockers
nonce: 'abc123', // optional CSP nonce
}
```
Set `anonymizeId: false` only when another script or test needs a stable DOM id. Pass `nonce` when your CSP requires it; c15t applies it directly to the generated `<script>` element.
You usually do not need a per-script `nonce`. Setting `nonce` once on the provider covers every injected script (and the theme stylesheet); a per-script value overrides it for that script alone.
## Dynamic Management
Framework packages expose script-manager methods so integrations can be added, removed, or inspected at runtime. Use this for tenant-specific tools, feature-flagged scripts, or vendors that are configured after sign-in:
* `setScripts(scripts)` â registers script definitions and immediately evaluates them against consent.
* `removeScript(id)` â removes a definition and unloads its element if needed.
* `isScriptLoaded(id)` â returns whether c15t has loaded a script.
* `getLoadedScriptIds()` â returns every currently loaded script id.
Dynamic scripts should still use stable ids. If the same vendor is added repeatedly with different ids, c15t treats each call as a new script.
## Calling Vendor APIs From Your App
The script loader controls **when the vendor SDK loads**. It does not intercept calls your application code makes to that SDK afterwards. Whether your event calls are safe before consent is granted depends on the script's persistence flags:
|Vendor pattern|What c15t does|What your app code must do|
|--|--|--|
|Consent-gated load, unloaded on revoke (e.g. cookieless analytics)|Script not in DOM until consent granted; removed on revoke. Global is `undefined` outside that window.|**Guard every call.** Unguarded `window.vendor.track(...)` throws when the global is absent.|
|Consent-gated load with `persistAfterConsentRevoked` (e.g. Meta Pixel)|Script not in DOM until consent granted; stays after revoke. c15t calls vendor's consent-revoke API on revocation.|Guard calls only for the pre-initial-consent window. Once loaded, the SDK handles its own suppression.|
|`alwaysLoad: true` with a vendor consent API (e.g. GTM, gtag, Databuddy, PostHog)|Script in DOM on page start; c15t signals consent state through the vendor's API.|Calls are safe â the vendor SDK suppresses transmission when consent is denied.|
|No app-facing API (e.g. Cloudflare Web Analytics)|Script in/out of DOM based on consent. Tracking is fully automatic.|Nothing to guard.|
The safe pattern in React is to read consent state through `useConsentManager().has(category)` before calling the SDK:
```tsx
import { useCallback } from 'react';
import { useConsentManager } from '@c15t/react';
function useTrackSignup() {
const { has } = useConsentManager();
return useCallback(() => {
if (has('measurement')) {
window.fathom?.trackEvent('signup');
}
}, [has]);
}
function SignupButton() {
const trackSignup = useTrackSignup();
return <button onClick={trackSignup}>Sign up</button>;
}
```
From non-React code, read the consent store directly:
```ts
import { getOrCreateConsentRuntime } from 'c15t';
const { consentStore } = getOrCreateConsentRuntime();
if (consentStore.getState().has('measurement')) {
window.fathom?.trackEvent('signup');
}
```
Each [integration page](/docs/integrations/overview) includes a vendor-specific **Tracking events in your app** block that names which pattern applies.
## Debugging Checklist
When a script does not behave as expected:
1. Confirm the script's `category` matches the consent that has been granted.
2. Check whether the script is `alwaysLoad` or consent-gated.
3. Confirm `onBeforeLoad` creates any globals before the vendor code reads them.
4. Confirm `onConsentChange` updates persisted or always-loaded scripts when consent changes.
5. Check whether the browser or an ad blocker blocked the request.
6. Use c15t devtools to inspect script lifecycle events when available.
## Dynamic Script Management
Add, remove, or check scripts at runtime via the store:
```ts
const state = consentStore.getState();
// Add scripts dynamically
state.setScripts([
{ id: 'dynamic', src: 'https://cdn.example.com/widget.js', category: 'measurement' },
]);
// Remove a script
state.removeScript('dynamic');
// Check if a script is loaded
const loaded = state.isScriptLoaded('custom-analytics');
// Get all loaded script IDs
const allLoaded = state.getLoadedScriptIds();
```
## API Reference
|Property|Value|
|:--|:--|
|Type Name|\`Script\`|
|Source Path|\`./packages/core/src/libs/script-loader/types.ts\`|
\*ExtractedTypeTable: Could not extract "Script" from "./packages/core/src/libs/script-loader/types.ts" using base path "/home/runner/work/c15t/c15t". Verify the path/name and that the file is included by your tsconfig.\*