---
title: Transports
description: Choose where a JavaScript app's c15t policy comes from and where
  choices go, with the hosted, manifest, offline and custom transports for
  @c15t/browser, createConsentRuntime and your own kernel.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## 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](/docs/concepts/modes) covers the same factories in every
framework, and [data fetching](/docs/concepts/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:

```txt
@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:

```ts title="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,
});
```

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

```txt
@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`:

```ts title="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' }),
});
```

|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](/docs/frameworks/javascript/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:

```ts title="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`:

```ts title="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](/docs/concepts/modes#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:

```sh
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](/docs/concepts/modes#what-happens-when-the-download-fails)
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`:

```ts title="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](/docs/concepts/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](/docs/frameworks/javascript/translations#switch-language-at-runtime).

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:

```ts title="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 };
		},
	}),
});
```

|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

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.
