JavaScript Advanced
Transports
Compare the transports
A transport tells the kernel which policy applies to this visitor and where to record their choice.
| Transport | Policy comes from | Choices go to | Use it when |
|---|---|---|---|
manifest() with a build-time snapshot, recommended | Public policy bundled during the build, resolved in the browser | The backend's /subjects | You use Vite and can rebuild after policy edits. Regional policies may still request location. |
hosted() | The backend's /init, per visit | The backend's /subjects | Policy changes must apply without rebuilding, or you need backend resolution on every visit. |
manifest() at runtime | The backend's public /manifest, resolved in the browser | The backend's /subjects | You want the banner to render without a per-visit /init request and cannot bundle the policy. |
| Offline | Rules bundled in your app | Nowhere; the browser only | Prototypes and tests. Not recommended for production environments. |
| Custom | Your own init function | Your own save function | You 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:
| Setup | Where it goes |
|---|---|
@c15t/browser | init({ mode }), a factory such as manifest(), hosted() or offline() from @c15t/browser. Required. |
createConsentRuntime | mode, a factory such as hosted({ backendURL }). Required. |
| Script tag | data-mode="hosted", "manifest" or "offline". Mode names work only in the script-tag builds. |
createConsentKernel | transport, 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:
Hosted
hosted() talks to your Inth or self-hosted backend. hosted({ backendURL })
from c15t requires the URL:
| Option | What it does |
|---|---|
backendURL | The backend URL. Relative URLs such as /api/c15t work behind a proxy. |
headers | Request 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. |
fetch | A fetch implementation, for tests or unusual runtimes. |
domain | The domain recorded with each save. Defaults to the page's host. |
initURL | Send /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. |
assertDecisionInputs | Send 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:
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:
| Option | What it does |
|---|---|
manifestURL | Where 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. |
snapshot | The 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. |
backendURL | Where 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. |
geoURL | A same-origin route that answers { country, region }, asked when the policy needs a location the page doesn't have. |
initFallback | false resolves for an unknown location instead of asking the backend's /init. Defaults to true. |
fetch | A 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:
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:
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:
| Command | Default when the fetch fails |
|---|---|
Production build: next build, vite build, nuxt build, astro build | The build stops with an error. |
Dev: next dev, vite dev, nuxt dev, astro dev | A 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:
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:
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:
| Method | Receives | Returns |
|---|---|---|
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
- With hosted mode, the Network tab shows one
/initrequest per fresh visit and a/subjectsrequest after a choice. - With a build-time manifest, it shows no
/manifestrequest. With runtime manifest mode, it shows a/manifestrequest. Neither calls/init, unless the policy needs a country the page did not supply. - With offline mode, it shows no c15t request at all, and the choice survives a reload.