Skip to main content

JavaScript Advanced

Transports

Compare the transports

A transport tells the kernel which policy applies to this visitor and where to record their choice.

TransportPolicy comes fromChoices go toUse it when
manifest() with a build-time snapshot, recommendedPublic policy bundled during the build, resolved in the browserThe backend's /subjectsYou use Vite and can rebuild after policy edits. Regional policies may still request location.
hosted()The backend's /init, per visitThe backend's /subjectsPolicy changes must apply without rebuilding, or you need backend resolution on every visit.
manifest() at runtimeThe backend's public /manifest, resolved in the browserThe backend's /subjectsYou want the banner to render without a per-visit /init request and cannot bundle the policy.
OfflineRules bundled in your appNowhere; the browser onlyPrototypes and tests. Not recommended for production environments.
CustomYour own init functionYour own save functionYou run your own consent API.

Consent modes covers the same factories in every framework, and data fetching compares the paths in more depth, including what each one sends.

Pass a transport

Each setup takes the transport in a slightly different form:

SetupWhere it goes
@c15t/browserinit({ mode }), a factory such as manifest(), hosted() or offline() from @c15t/browser. Required.
createConsentRuntimemode, a factory such as hosted({ backendURL }). Required.
Script tagdata-mode="hosted", "manifest" or "offline". Mode names work only in the script-tag builds.
createConsentKerneltransport, the transport object itself, such as createHostedTransport({ backendURL }).

A factory is created once. To switch transports, dispose the client or runtime and create a new one.

init() from @c15t/browser imports no transport itself, so the bundle keeps only the factory you import. Passing a name throws:

@c15t/browser: `mode` must be a factory, such as manifest(), hosted() or offline() from @c15t/browser. Mode names like "hosted" work only in the script-tag builds.

Hosted

hosted() talks to your Inth or self-hosted backend. hosted({ backendURL }) from c15t requires the URL:

src/consent-runtime.ts
import { hosted } from 'c15t';
import { createConsentRuntime } from 'c15t/runtime';

import { scripts } from './scripts';

// One runtime for the page: policy, stored choices, script loading,
// iframe blocking and the reload after a revocation.
export const runtime = createConsentRuntime({
	mode: hosted({ backendURL: 'https://your-project.inth.app' }),
	scripts,
});
OptionWhat it does
backendURLThe backend URL. Relative URLs such as /api/c15t work behind a proxy.
headersRequest headers to forward on /init: accept-language, the geo headers and sec-gpc. Meant for server code forwarding a visitor's request. In a browser, any but accept-language makes a cross-origin /init wait for a CORS preflight; pass overrides through the runtime's overrides instead, which travel in the query string.
fetchA fetch implementation, for tests or unusual runtimes.
domainThe domain recorded with each save. Defaults to the page's host.
initURLSend /init somewhere else, such as a same-origin route, while saves still go to backendURL. Saves then assert the policy they were made against. Its query string may carry your own parameters, but c15t replaces any named v, contract, country, region, gpc, experiment, journey, journeyScope or stored with its own value.
assertDecisionInputsSend the resolved policy inputs with each save, so the backend can reject a save made against a stale policy. Defaults to true when initURL is set.

hosted() from @c15t/browser takes the same options, and backendURL defaults to the URL consentManifest() from c15t/build read from VITE_C15T_BACKEND_URL or VITE_INTH_PROJECT_URL. Without the plugin or the option it throws:

@c15t/browser: hosted() needs `backendURL`. Pass it, or add consentManifest() from c15t/build to your Vite config and set VITE_C15T_BACKEND_URL (or VITE_INTH_PROJECT_URL).

A production build prints only the first sentence.

Add your app's origin to the Inth project's trusted origins, or the browser blocks consent saves with a CORS error.

A cross-origin /init is a CORS simple request: the client version, policy contract, overrides and experiment arm go in the query string, and the request carries no cookies, so the browser sends it without an OPTIONS preflight. Saves still go out with cookies and still need the trusted origin.

Manifest

manifest() from @c15t/browser resolves the policy in the browser from the backend's public manifest. With no options it uses the snapshot and backend URL consentManifest() downloaded; without a snapshot it fetches ${backendURL}/manifest once. It works with init() and with createConsentRuntime:

src/consent-runtime.ts
import { manifest } from '@c15t/browser';
import { createConsentRuntime } from 'c15t/runtime';

// Fetch the backend's public manifest and resolve the policy in the browser.
// Saves still go to the backend.
export const runtime = createConsentRuntime({
	mode: manifest({ manifestURL: 'https://your-project.inth.app/manifest' }),
});
OptionWhat it does
manifestURLWhere to fetch the manifest when the page loads. The build's snapshot is not used, but saves still go to the build's backend URL. Without one, a URL that ends in /manifest also gives the backend URL.
snapshotThe manifest object itself, inlined into the page. No manifest request at all. Defaults to the build's snapshot unless you set manifestURL.
source'runtime' ignores the build's snapshot and fetches the manifest. You can pass snapshot or source, not both.
backendURLWhere saves go. Defaults to the build's backend URL. Required otherwise, unless manifestURL ends in /manifest. '' means this origin.
inputs{ country, region } known ahead of time, such as a country your edge injected into the page.
geoURLA same-origin route that answers { country, region }, asked when the policy needs a location the page doesn't have.
initFallbackfalse resolves for an unknown location instead of asking the backend's /init. Defaults to true.
fetchA fetch implementation.

When some locations get a different banner than others, or no banner, and neither the page nor geoURL supplies a country, the transport asks the backend's /init instead, so the answer stays correct. That visitor's banner waits for the round trip. Rules keyed by country or region that all give the same banner (same model, prompt, categories, copy and GPC handling, only the rule id differs) resolve in the browser, so the banner shows without a request. Such a manifest needs a default rule, plus a fallback rule when it has region rules, so that every location matches one. When the location is unknown, an IAB policy behind country or region rules still goes to /init. manifestNeedsLocation(manifest) from @c15t/browser tells you in advance.

The browser bundle carries English base copy. Another language's base copy loads the first time a visitor needs it, and the manifest's own translations apply on top.

A manifest resolved in the browser without a known location reports no location: getSnapshot().location and useLocation() have a null country and region. Code that reads the visitor's country should supply it through inputs, or use /init. IAB GPP needs it for its US state sections, so when GPP is on, through the runtime's gpp option, <ConsentGPP> or mountGPP(), an unknown location goes to /init even when every location gets the same banner. The script-tag build counts GPP as on, because c15t.gpp.js can load after init(); pass gpp: false to c15t.init() to resolve in the browser on pages that do not load it.

createManifestTransport from c15t/transports/manifest is for server code only. It bundles every language. Browser code uses manifest(), or createBrowserManifestTransport from c15t/transports/manifest-browser.

Bundle the manifest during Vite builds

The quickstart and examples/javascript in the c15t repository use this setup.

The build fetches your public policy once and bundles it, so the server never fetches it at runtime.

Install c15t@alpha alongside @c15t/browser for the build plugin. Add it to your existing Vite plugins:

vite.config.ts (plugins option)
import { consentManifest } from 'c15t/build';

export default {
	plugins: [
		// Keep your existing framework plugins here.
		consentManifest({ backendURL: 'https://your-project.inth.app' }),
	],
};

Without backendURL, the plugin reads VITE_C15T_BACKEND_URL, then VITE_INTH_PROJECT_URL. The plugin fetches the policy during vite build, or in vite dev when the app first loads it, and serves it as snapshot from c15t/generated. A build fetches only when the app uses manifest(). In your browser entry point, pass manifest() as the mode of the existing init() call. It reads the snapshot and the backend URL from c15t/generated:

src/main.ts
import { init, manifest } from '@c15t/browser';

init({ mode: manifest() });

Keep your scripts and UI options. The browser resolves the snapshot without a manifest request when the policy does not need geography, or the required country and region are known through overrides. When required geography is missing, it calls backend /init and uses the backend's resolved policy and translations. Those visitors still wait for a backend round trip and can receive newer policy than the build snapshot.

When the build has no snapshot, as in vite dev after a failed fetch, snapshot is undefined, and the browser fetches ${backendURL}/manifest instead.

A script-tag site without a build step cannot run this hook.

The snapshot is fixed at build time:

  • Rebuild after changing policies, translations or vendors. If your CI caches build output, force a fresh build.
  • Consent choices still go to the backend.

The build reads the backend URL from your public backend URL variable when the config doesn't pass one: NEXT_PUBLIC_C15T_BACKEND_URL in Next.js, NUXT_PUBLIC_C15T_BACKEND_URL in Nuxt, PUBLIC_C15T_BACKEND_URL in Astro, Svelte and SvelteKit, and VITE_C15T_BACKEND_URL in TanStack Start and other Vite apps. Each also reads the matching Inth variable, such as NEXT_PUBLIC_INTH_PROJECT_URL, when the c15t one is unset. See set the backend URL.

The fetch waits at most 10 seconds. When it fails, or no backend URL is set, every framework does the same thing:

CommandDefault when the fetch fails
Production build: next build, vite build, nuxt build, astro buildThe build stops with an error.
Dev: next dev, vite dev, nuxt dev, astro devA warning, and the server fetches the policy at runtime.

Set onBuildError to use one behaviour for both. 'fail' stops dev too. 'runtime' lets a production build finish, and the server fetches the policy at runtime. The C15T_ON_BUILD_ERROR environment variable overrides the option, so you can deploy during a backend outage without a code change:

C15T_ON_BUILD_ERROR=runtime npm run build

Turborepo's strict environment mode hides undeclared variables from tasks, so list C15T_ON_BUILD_ERROR in the build task's passThroughEnv there.

The build skips the fetch, without an error, when it can't use a snapshot, for example when the backend URL is relative. With onBuildError: 'fail', a relative URL stops the build. Consent modes lists every case.

vite build and vite dev fetch the manifest when they start. vite preview serves the last build without fetching. The plugin writes no file into your app, so there is nothing to keep out of Git. c15t/generated ships its own types, so tsc, vue-tsc and svelte-check pass on a fresh checkout without a build first.

The plugin can't see the options your app passes to manifest(). When the app passes source: 'runtime' or manifestURL, set consentManifest({ source: 'runtime' }) too. The build then downloads no manifest, so it doesn't fail when the backend's /manifest is down.

For policy updates without a rebuild, use hosted(), which asks the backend's /init on each page load, or manifest({ source: 'runtime' }), which fetches the manifest at runtime. Both keep reading the backend URL from the plugin.

Offline

offline() resolves bundled rules in the browser and sends nothing. c15t and @c15t/browser export the same offline(), so it works with init() and with createConsentRuntime:

src/consent-runtime.ts
import { offline } from '@c15t/browser';
import { policyRulePresets } from 'c15t';
import { createConsentRuntime } from 'c15t/runtime';

// No backend: rules resolve in the browser and choices stay there. The
// browser does not know the visitor's country, so the page supplies it.
export const runtime = createConsentRuntime({
	mode: offline({
		policyRules: [
			policyRulePresets.europeOptIn(),
			policyRulePresets.worldNone(),
		],
	}),
	overrides: { country: document.documentElement.dataset.country },
});

Without policyRules, it uses c15t's recommended rules. They are strict opt-in for Europe, the UK, Quebec and unknown locations, opt-out for US states with a privacy law, and no prompt elsewhere. The browser cannot see the visitor's country, so without a country override every visitor gets the strict opt-in fallback. Choices stay in that browser and there are no consent records. Not recommended for production environments.

init() from @c15t/browser takes mode: offline({ policyRules }) with rule objects from policyRulePresets. Preset names such as ['europeOptIn', 'worldNone'] work only in the script-tag builds, through data-policy-rules or a queued policyRules. Policies lists the presets.

In offline mode, a language your app sets with overrides.language, setLanguage() or kernel.set.language() switches the copy when the bundle or i18n.messages has that language. A language with no copy shows the default copy. The language a server prefetch detected from Accept-Language does not switch the copy. See translations.

For a kernel you create yourself, createOfflineTransport({ policyRules }) from c15t returns the transport object. Pass it translationsFor, a function that returns the copy for a language or undefined, and detectedLanguage to get the same language switching. Without translationsFor, the transport serves its starting copy under whatever language the kernel asks for.

Custom

custom(transport) from c15t wraps your own init and save. This one resolves the policy locally and records choices with your API:

src/consent-runtime.ts
import { createOfflineTransport, custom, policyRulePresets } from 'c15t';
import { createConsentRuntime } from 'c15t/runtime';

// Resolve the policy in the browser and record choices with your own API.
const local = createOfflineTransport({
	policyRules: [policyRulePresets.europeOptIn()],
});

export const runtime = createConsentRuntime({
	mode: custom({
		init: local.init,
		async save(payload) {
			const response = await fetch('/api/consent', {
				body: JSON.stringify({
					choice: payload.choice,
					consents: payload.consents,
					subjectId: payload.subjectId,
				}),
				headers: { 'content-type': 'application/json' },
				method: 'POST',
			});
			return { ok: response.ok, subjectId: payload.subjectId };
		},
	}),
});
MethodReceivesReturns
init(context){ overrides, user }An init response with a policyResolution. Build it with createOfflineTransport, or return what a c15t backend returns.
save(payload)The choice, the resulting permissions, the subject ID, the decision inputs and a policySnapshotToken{ ok, subjectId? }. Return ok: false or throw to have the kernel keep and retry the payload.
identify(user, subjectId)Optional.Link a signed-in user.
loadSubjectRecord(subjectId)Optional.Stored records for a subject, applied without counting as a choice.

Every method is optional; a missing one makes its command succeed without a request. An init response without a valid policyResolution fails safely: optional categories stay denied and no banner shows. custom() rejects v2 endpoint handlers such as setConsent.

Check it works

  1. With hosted mode, the Network tab shows one /init request per fresh visit and a /subjects request after a choice.
  2. With a build-time manifest, it shows no /manifest request. With runtime manifest mode, it shows a /manifest request. Neither calls /init, unless the policy needs a country the page did not supply.
  3. With offline mode, it shows no c15t request at all, and the choice survives a reload.