Skip to main content

JavaScript

Quickstart

Connect Inth

Create an Inth project, set its policy rules, add your app's origin to its trusted origins, and copy the project's backend URL.

This guide uses @c15t/browser as an ES module in a Vite app, with your project's policy bundled at build time. init() starts the consent runtime and mounts the stock banner and preference dialog. For your own UI, use the headless runtime instead. Sites without a build step load the same package from a script tag; choose your setup points to that guide.

Bundling the policy at build time is the recommended production setup. Rebuild after changing policies, translations or vendors.

Install

npm install @c15t/browser@alpha c15t@alpha @c15t/integrations@alpha

@c15t/browser is not part of the c15t package. c15t supplies the Vite plugin at c15t/build, and @c15t/integrations the PostHog helper used below.

Bundle your policy

Add consentManifest to vite.config.ts:

vite.config.ts
import { consentManifest } from 'c15t/build';
import { defineConfig } from 'vite';

export default defineConfig({ plugins: [consentManifest()] });

The plugin reads VITE_C15T_BACKEND_URL. Set it to your project's backend URL, including any path prefix, in .env or wherever you build:

.env
VITE_C15T_BACKEND_URL=https://your-project.inth.app

If your app already sets VITE_INTH_PROJECT_URL for other Inth SDKs, that works too. When both are set, VITE_C15T_BACKEND_URL wins.

consentManifest downloads your project's public policy from /manifest during vite build, or in vite dev when the app first loads it, and serves it to your app as snapshot from c15t/generated. No file is written into your project.

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.

Start c15t

Call init() once, in your browser entry point:

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

init({
	mode: manifest(),
	scripts: [
		posthog({
			id: 'phc_your_project_key',
			initOptions: { cookieless_mode: 'never' },
			loadMode: 'after-consent',
		}),
	],
});

manifest() resolves the policy from the bundled snapshot in the browser, with no /manifest request. It reads the snapshot and the backend URL from what consentManifest downloaded. Consent choices go to that backend, and so does an /init request when the policy depends on the visitor's country or region and overrides does not supply it. That request returns the backend's current policy, which can be newer than the snapshot. Vite reads VITE_C15T_BACKEND_URL at build time, so set it before you build, not when you serve the files.

To apply policy edits without rebuilding, pass hosted() from @c15t/browser as the mode instead. The browser then requests the policy from /init on every page load. Your bundle keeps only the mode you import.

PostHog waits for measurement permission with loadMode: 'after-consent', and cookieless_mode: 'never' turns off its cookieless capture. Replace phc_your_project_key with your PostHog project key. The PostHog helper sends events to PostHog's EU cloud; for a US project, add region: 'us'.

Include the measurement category in your Inth policy. Remove any other loader for PostHog, such as a snippet in index.html, so it loads once and only through c15t. Add other vendors to the same list, each with the category it needs; the integration guides give the helper for each one.

init() returns the client. It reads stored choices, resolves the visitor's policy, shows the banner when the policy needs one, and loads each script once its category is allowed. The banner renders in a shadow root with its own styles, so you do not import a stylesheet. The client lasts for the page; call consent.dispose() only if your app removes consent entirely.

When a visitor withdraws a permission they had given, the client saves the choice and reloads the page, because code that already ran cannot be unloaded. Scripts explains how to change that.

Gate embeds and add a privacy settings button

Put the embed's URL in data-src and name its category in data-category. The client sets src only while that category is allowed:

index.html
<iframe
	data-src="https://www.youtube-nocookie.com/embed/czTksCF6X8Y?playsinline=1"
	data-category="measurement"
	title="YouTube video"
	allow="encrypted-media; picture-in-picture"
	allowfullscreen
></iframe>

Visitors need a way to change their choice after the banner closes. The client opens the preference dialog from any link to #c15t-preferences:

index.html
<a href="#c15t-preferences">Privacy settings</a>

A button with data-c15t-action="customize" opens it too, without code.

Change the look

Pass theme tokens in ui.theme, next to the options your init() call already has. This example turns the buttons purple and rounds the cards:

src/main.ts
const consent = init({
	mode: hosted({ backendURL: 'https://your-project.inth.app' }),
	scripts,
	ui: {
		theme: {
			colors: {
				primary: '#6943a3',
				primaryHover: '#533285',
				textOnPrimary: '#ffffff',
			},
			radius: { lg: '18px' },
		},
	},
});

theme takes the same tokens as every c15t package; see theme tokens. presentation.prompt changes the banner's shape and position, ui.css adds CSS inside the shadow root, and i18n changes the copy. Customize covers each option.

Check it works

Run the app, open it in a private window, and open the browser's developer tools on the Network tab.

  1. Before you choose, filter for posthog. There are no requests, and the YouTube iframe has no src.
  2. Click Reject All and reload. The banner stays closed and the PostHog requests stay absent.
  3. Click Privacy settings, turn on Analytics (the measurement category) and save. PostHog and the YouTube player load.
  4. Open Privacy settings again and turn Analytics off. The page reloads, and PostHog and YouTube do not load again.

If no banner appears, see why is there no banner. Run the full verification checklist before you ship.

Next steps

  • Render your own banner with the headless runtime.
  • Inspect consent with DevTools.
  • Read consent from your code. consent.has('measurement') answers whether a category is allowed now, consent.on('consent', listener) reports changes, and consent.save({ measurement: true }) records a choice. The @c15t/browser API lists every method, and options every option.
  • Run code on consent events with callbacks.
  • Add languages with translations.