---
title: Troubleshooting
description: Diagnose vendor scripts that run early, unexpected reloads, empty
  custom banners, CORS errors, duplicate consent owners and policy resolution in
  a JavaScript app that uses @c15t/browser or c15t/runtime.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Why do scripts load twice or before consent?

**Check.** Each vendor needs one owner. Look for a snippet left in
`index.html`, a second `init()` call, or a script loader attached to a kernel
that `@c15t/browser` or `createConsentRuntime` already owns. Each of these loads
vendors outside the consent gate or twice.

**Fix.** Call `init()` or create the runtime once per page, in the browser
entry point, and pass every vendor through its `scripts` list.

## Why does the page reload when a visitor saves?

**Check.** Did the visitor turn off a category they had allowed?
`@c15t/browser` and `createConsentRuntime` reload the page then, because code
that already ran cannot be unloaded. Allowing categories never reloads.

**Fix.** To handle withdrawal yourself, pass `reloadOnConsentRevoked: false`,
and stop each vendor with its own opt-out call. See
[scripts](/docs/frameworks/javascript/scripts#when-a-visitor-withdraws-permission).

## Why is my custom banner empty?

**Check.** Confirm that you subscribed before calling `runtime.start()`, that
you wait for `resolution.status` to be `'matched'`, and that you render
`promptRequirement.kind` `'notice'` as well as `'choice'`.

**Fix.** Render the headless UI from the snapshot, not from assumptions.
[Headless](/docs/frameworks/javascript/headless#what-your-ui-must-handle) lists
what a custom UI must cover.

## Why does the build fail to download the manifest?

**Check.** `vite build` stops with an error from `consentManifest` that
starts with the plugin's name, such as
`@c15t/core/build: could not fetch the consent manifest from <url> during the build`. The name is `@c15t/core/build` for `c15t/build` and `@c15t/vue/vite`
for `c15t/vue/vite`. `vite dev` logs the same message as a warning and keeps
going. The part in parentheses names the cause:

|Message|Cause|Fix|
|--|--|--|
|`fetch failed`, with `ENOTFOUND`, `ECONNREFUSED` or another network error|The build cannot reach the backend|Build where the backend is reachable|
|`no response within 10 seconds`|The backend did not answer in time|Check the backend, or build where it is reachable|
|`/manifest responded 404`|The URL is not your project's backend, such as the `https://your-project.inth.app` placeholder or a URL missing its path prefix|Copy the backend URL exactly as Inth shows it, or set `VITE_C15T_BACKEND_URL`|
|`/manifest returned an invalid consent manifest.`|The URL answered with something other than a consent manifest, such as an HTML page|Point `backendURL` at the backend itself, not your site or a dashboard page|

A failed download never reuses an old snapshot. To deploy while the backend
is down, run the build with `C15T_ON_BUILD_ERROR=runtime`. `snapshot` is then
`undefined`, and the browser fetches the policy from the backend at runtime.

Only a build that uses `manifest()` downloads the manifest. The plugin
fetches after Vite has dropped the code the app does not use, so a `hosted()`
or `offline()` build never contacts the backend and never fails this way.

An error or warning that says `no backend URL is set` means the plugin found
no `backendURL` option and no variable. Set `VITE_C15T_BACKEND_URL` (or
`VITE_INTH_PROJECT_URL`) in `.env` or the build environment, or pass the absolute backend URL from your Inth
project. The [troubleshooting guide](/docs/guides/troubleshooting#why-does-the-build-fail-to-download-the-manifest)
lists every message.

A notice that the build `skipped the consent manifest fetch` means the backend
URL is relative, such as `/api/c15t`. Pass the absolute backend URL instead.
With `onBuildError: 'fail'`, this stops the build with `build-time manifests require an absolute upstream URL`.

## Why is snapshot undefined?

**Check.** `snapshot` from `c15t/generated` is `undefined`, and the browser
requests `${backendURL}/manifest` on page load.

**Fix.** `snapshot` is `undefined` when Vite has no policy to serve:

* `consentManifest` is missing from `plugins` in `vite.config.ts`. Without the
  plugin, `c15t/generated` still resolves, but exports `undefined`.
* The fetch failed in `vite dev`, or in a build with
  `C15T_ON_BUILD_ERROR=runtime`. The terminal shows a `could not fetch the consent manifest` warning.
* The backend URL is relative, so the plugin skipped the fetch.
* `consentManifest({ source: 'runtime' })` is set, so the plugin never
  fetches.

Fix the cause, then restart `vite dev` or rebuild. The plugin fetches once
when Vite starts.

## Why does /init fail with a CORS or CSP error?

**Check.** A CORS error means the backend does not trust your app's origin.

**Fix.** Add the exact origin, including the port in development, to your Inth
project's trusted origins.

**Check.** A CSP error means your policy's `connect-src` lacks the backend's
origin.

**Fix.** Add the backend's origin to `connect-src`. See
[Content Security Policy](/docs/frameworks/javascript/content-security-policy).

## Why does a gated script in my HTML never run?

**Check.** With the `nonce` option set, `@c15t/browser` runs only
`<script type="text/plain" data-c15t-category>` tags that carry the same
nonce. Open the console and look for a warning that c15t skipped a
`data-c15t-category` script, and check the tag for
`data-c15t-activated="untrusted"`.

**Fix.** Add the page's nonce to the tag, including tags your code inserts
later. See
[Content Security Policy](/docs/frameworks/javascript/content-security-policy).

## Why is the stock banner unstyled?

**Check.** Look at your Content Security Policy's `style-src`. If it does not
allow the `<style>` element the stock UI adds, the policy blocks it.

**Fix.** Pass the nonce from your `style-src` as the `nonce` option, or import
`@c15t/browser/styles.css` with `ui: { shadow: false, styles: false }` as
[Content Security Policy](/docs/frameworks/javascript/content-security-policy#allow-the-stock-uis-styles)
describes.

## Why does a fetch return status 451?

**Check.** A [network blocker](/docs/frameworks/javascript/modules/network-blocker)
rule matched the request and its category is not allowed. The console logs
`[c15t] blocked` with the rule's ID.

**Fix.** If the request should not match, check the rule's `domain` and
`pathIncludes`.

## Inspect the active policy

Call this helper with your kernel: `consent.kernel` from `@c15t/browser`,
`runtime.kernel` from `createConsentRuntime`, or one you created. It logs the
current state and later changes without recording a choice.

```ts title="src/observe-consent-policy.ts"
import type { ConsentKernel } from 'c15t';

export function observeConsentPolicy(kernel: ConsentKernel) {
	const report = () => {
		const snapshot = kernel.getSnapshot();
		const resolution = snapshot.resolution;

		console.log({
			status: resolution.status,
			policy: resolution.status === 'matched' ? resolution.policy.id : null,
			prompt: snapshot.promptRequirement,
			permissions: snapshot.effectivePermissions,
			location: snapshot.location,
			privacySignals: snapshot.privacySignals,
		});
	};

	report();
	return kernel.subscribe(report);
}
```

The helper returns an unsubscribe function. Call it when you remove the
diagnostic or dispose the app. Production gates should read
`kernel.getSnapshot().effectivePermissions` at the point where an optional
feature would run.

## More help

[Troubleshoot consent](/docs/guides/troubleshooting) covers problems shared by
every framework, such as a missing banner, analytics that load before a choice,
imports that fail because npm installed c15t v2, choices that disappear on
reload, server HTML that differs from the browser, static builds that fail and
content blockers that hide the consent UI.
