Guides

Script Loader

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; custom scripts can be declared directly when the vendor is specific to your app.

npm install @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.

Info

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 scripts to ConsentManagerProvider. Built-in helpers from @c15t/scripts return plain Script objects, so they sit beside app-specific scripts:

import { type ReactNode } from 'react';
import { ConsentManagerProvider } from '@c15t/nextjs';
import { metaPixel } from '@c15t/scripts/meta-pixel';

export function ConsentManager({ children }: { children: ReactNode }) {
  return (
    <ConsentManagerProvider
      options={{
        mode: 'hosted',
        backendURL: '/api/c15t',
        scripts: [
          metaPixel({ pixelId: '123456' }),
          {
            id: 'custom-analytics',
            src: 'https://cdn.example.com/analytics.js',
            category: 'measurement',
          },
        ],
      }}
    >
      {children}
    </ConsentManagerProvider>
  );
}

The script loader runs in the browser, so place the provider in the client boundary that owns your consent UI and pass scripts through the provider options instead of injecting them in Server Components.

Define your script list once and keep it next to the consent provider:

'use client';

import { type ReactNode } from 'react';
import { ConsentManagerProvider } from '@c15t/nextjs';
import { gtag } from '@c15t/scripts/google-tag';
import { metaPixel } from '@c15t/scripts/meta-pixel';

const scripts = [
  gtag({ id: 'G-XXXXXXX' }),
  metaPixel({ pixelId: '123456' }),
];

export function ConsentProvider({ children }: { children: ReactNode }) {
  return (
    <ConsentManagerProvider
      options={{
        mode: 'hosted',
        backendURL: '/api/c15t',
        scripts,
      }}
    >
      {children}
    </ConsentManagerProvider>
  );
}

Use environment variables or server-rendered configuration to choose ids per environment, but avoid rendering vendor scripts with next/script separately — that bypasses c15t's lifecycle for that vendor.

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 and persistAfterConsentRevoked — 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:

StyleUse when
Built-in helper from @c15t/scriptsc15t already ships the vendor. See the integrations overview.
Plain ScriptOne-off app code with simple load and callback behavior.
Callback-only ScriptAnother package already loaded the SDK; c15t only synchronizes consent.
Manifest-backed helperReusable vendor integration with structured setup phases, queues, stubs, or a vendor consent API.
Iframe / renderable integrationVendor 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:

{
  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.

{
  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:

{
  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 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 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.
{
  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);
  },
}

The category field accepts a HasCondition. It can be a single consent category or a logical expression:

// 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.

{
  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.

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.

{
  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:

QuestionalwaysLoadpersistAfterConsentRevoked
Loads before consent is granted?YesNo (waits for consent like a normal script)
Stays loaded after consent is revoked?YesYes
Requires a vendor consent API?YesYes

DOM Placement

Control where the script is injected and whether the element id is anonymized:

{
  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 patternWhat c15t doesWhat 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:

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:

import { getOrCreateConsentRuntime } from 'c15t';

const { consentStore } = getOrCreateConsentRuntime();

if (consentStore.getState().has('measurement')) {
  window.fathom?.trackEvent('signup');
}

Each integration page 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

The shared guide above lists what the script-manager methods do. In Next.js they are exposed through useConsentManager():

import { useConsentManager } from '@c15t/nextjs';

function ScriptManager() {
  const {
    setScripts,
    removeScript,
    isScriptLoaded,
    getLoadedScriptIds,
  } = useConsentManager();

  // ...
}

Register dynamic scripts from a client effect or event handler — never directly in the render body — so React can run the call once per dependency change and tear it down on unmount:

'use client';

import { useEffect } from 'react';
import { useConsentManager } from '@c15t/nextjs';

export function WorkspaceAnalytics({ workspaceId }: { workspaceId: string }) {
  const { setScripts, removeScript } = useConsentManager();

  useEffect(() => {
    const scriptId = `workspace-analytics-${workspaceId}`;

    setScripts([
      {
        id: scriptId,
        src: `https://cdn.example.com/workspaces/${workspaceId}.js`,
        category: 'measurement',
      },
    ]);

    return () => {
      removeScript(scriptId);
    };
  }, [workspaceId, setScripts, removeScript]);

  return null;
}

Renderable Integrations

Some integrations need a visible component, not just a script tag. Google Maps, YouTube, checkout widgets, and calendars all need consent gating plus component lifecycle.

Use the Google Maps and YouTube renderable integrations through @c15t/nextjs in client components:

'use client';

import { GoogleMap, YouTubeEmbed } from '@c15t/nextjs';

export function ConsentAwareMedia() {
  const googleMapsApiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;

  return (
    <>
      {googleMapsApiKey && (
        <GoogleMap
          apiKey={googleMapsApiKey}
          authReferrerPolicy="origin"
          center={{ lat: 40.7128, lng: -74.006 }}
          className="overflow-hidden rounded-xl"
          consentCategory="measurement"
          zoom={12}
        />
      )}

      <YouTubeEmbed
        consentCategory="marketing"
        title="Product demo"
        videoId="dQw4w9WgXcQ"
      />
    </>
  );
}

The implementation splits the problem into three layers:

  1. Use the script loader as the source of truth for shared SDK loading.
  2. Use useConsentScript() for SDK readiness when a vendor uses a callback-style loader.
  3. Create the widget instance only on the client and clean it up on unmount.

Google Maps is SDK-backed and uses one shared c15t-managed script plus per-component map instances. The script is never requested before consent. Once loaded, its manager registration is retained to respect Google's one-loader-per-page constraint, while each map instance is destroyed on unmount or when consent becomes unavailable. The component adopts an existing Google Maps global, has a visible 320px default height, uses the official API types, and reports loader, authentication, and constructor failures through onError and errorFallback.

All maps with the same scriptId must use the same loader configuration. mapId changes recreate the instance; center, zoom, and updateable options update it in place. You can pass Google's direct-loader options through libraries, language, region, version, authReferrerPolicy, mapIds, channel, and solutionChannel.

YouTube is iframe-only and uses the existing Frame boundary rather than the YouTube iframe API. It defaults to a responsive, borderless 16:9 frame with a localized loading state, stable placeholder dimensions, native lazy loading, and the privacy-enhanced host. Its className and forwarded ref target the iframe; wrapperClassName and frameProps target the Frame. Boolean params become 1 or 0.

For a custom client-side SDK, useConsentScript() exposes blocked, loading, ready, and error states plus readyValue, error, and ready. Change its retryKey after a failed attempt to retry the same script id. Registrations with one id must use compatible script and readiness options; conflicts surface as ConsentScriptConflictError.

For iframe-only embeds, use the iframe blocking pattern or YouTubeEmbed. For SDK-backed widgets, load the SDK once and let each component instance create and clean up its own widget. Avoid mixing next/script with c15t for the same vendor.

App Router Notes

  • The provider and any component that calls useConsentManager() must be client components.
  • Keep vendor ids out of static examples when they differ per environment — read them from process.env.NEXT_PUBLIC_* or runtime config.
  • If a script must be available before a page becomes interactive, prefer a built-in helper that models denied-consent defaults rather than adding a separate next/script tag.
  • If you use CSP nonces, set nonce on the provider options so c15t applies it to every injected script element and the theme stylesheet. See Content Security Policy for reading the nonce in the App Router.

API Reference

Warning: ExtractedTypeTable: Could not extract "Script" from "./packages/core/src/libs/script-loader/types.ts" using base path "/vercel/path0/apps/c15t-docs/.leadtype/c15t". Verify the path/name and that the file is included by your tsconfig.

Loading…