---
title: Quickstart
description: Add the stock c15t banner, preferences and script gating to a
  JavaScript app built with Vite or another bundler, with no UI framework, using
  @c15t/browser.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Connect Inth

[Create an Inth project](https://inth.com), 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](/docs/frameworks/javascript/headless) instead. Sites
without a build step load the same package from a script tag;
[choose your setup](/docs/concepts/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

|Package manager|Command|
|:--|:--|
|npm|`npm install @c15t/browser@alpha c15t@alpha @c15t/integrations@alpha`|
|pnpm|`pnpm add @c15t/browser@alpha c15t@alpha @c15t/integrations@alpha`|
|yarn|`yarn add @c15t/browser@alpha c15t@alpha @c15t/integrations@alpha`|
|bun|`bun add @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`:

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

```sh title=".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](/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.

## Start c15t

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

```ts title="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](/docs/integrations/overview) 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](/docs/frameworks/javascript/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:

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

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

```ts title="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](/docs/customization/tokens). `presentation.prompt` changes the
banner's shape and position, `ui.css` adds CSS inside the shadow root, and
`i18n` changes the copy. [Customize](/docs/frameworks/javascript/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](/docs/guides/troubleshooting#why-is-there-no-banner).
Run the full [verification checklist](/docs/guides/verify-consent) before you
ship.

## Next steps

* Render your own banner with the [headless runtime](/docs/frameworks/javascript/headless).
* Inspect consent with [DevTools](/docs/frameworks/javascript/dev-tools).
* 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](/docs/frameworks/javascript/api/browser) lists every
  method, and [options](/docs/frameworks/javascript/api/browser-options) every
  option.
* Run code on consent events with [callbacks](/docs/frameworks/javascript/callbacks).
* Add languages with [translations](/docs/frameworks/javascript/translations).
