Skip to main content

Svelte

Quickstart

Before you start

This guide is for a Svelte 5 app without SvelteKit, such as a Vite single-page app. The browser resolves consent after the page loads, so the banner appears after the first paint. For a banner in the server HTML, use SvelteKit; choose your setup compares the options. The examples/svelte app in the c15t repository is the finished result.

The guide uses Inth for policies and consent records. Create an Inth project, then:

  1. Set its policy rules, including the measurement category for the vendor below.
  2. Add your app's origin, such as http://localhost:5173, to its trusted origins.
  3. Copy the project's backend URL.

A self-hosted backend or offline mode works too; see other setups.

The guide uses hosted() mode. The browser asks your backend for each visitor's policy, and the backend reads the visitor's location from the request, so regional policies need no extra setup. Policy, translation and vendor edits reach visitors on their next page load, without a rebuild.

Install

npm install @c15t/svelte@alpha @c15t/integrations@alpha

Svelte uses its own package, @c15t/svelte, which exports the components and the consent modes. @c15t/integrations holds the vendor helpers. The c15t package has no Svelte entry point, so you don't install it.

Add the Vite plugin

Add consentManifest to the plugins in vite.config.ts:

vite.config.ts
import { consentManifest } from '@c15t/svelte/vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';

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

Set VITE_C15T_BACKEND_URL to your project's backend URL, including any path prefix, in .env or your build environment:

.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 hands the backend URL to hosted(). No file is written into your project. Without the plugin, pass the URL as hosted({ backendURL }).

The plugin also adds a small script to index.html that sends /init while your JavaScript downloads, so the banner shows sooner. Send /init early covers when to turn it off.

Mount the provider

Render ConsentProvider around your app in src/App.svelte, with the vendor scripts c15t should load:

src/App.svelte
<script lang="ts">
	import { posthog } from '@c15t/integrations/posthog';
	import {
		ConsentBanner,
		ConsentDialog,
		ConsentDialogLink,
		ConsentProvider,
		hosted,
	} from '@c15t/svelte';

	const scripts = [
		posthog({
			id: 'phc_your_project_key',
			initOptions: { cookieless_mode: 'never' },
			loadMode: 'after-consent',
		}),
	];
</script>

<ConsentProvider mode={hosted()} {scripts}>
	<main>
		<h1>c15t + Svelte</h1>
		<p>Your app goes here.</p>
	</main>
	<footer>
		<ConsentDialogLink>Privacy settings</ConsentDialogLink>
	</footer>
	<ConsentBanner />
	<ConsentDialog />
</ConsentProvider>

The <main> element stands in for your app's content. hosted() from @c15t/svelte makes one /init request to VITE_C15T_BACKEND_URL, started early by the script in index.html. The backend picks the policy from the visitor's location and language and returns its current version. Consent choices go to the same backend. Vite writes the variable into the bundle at build time, so changing it means a rebuild.

PostHog waits for measurement permission. Replace phc_your_project_key with your PostHog project key. Remove any <script> tags or SDK imports that already load PostHog, so c15t is the only thing that loads it.

The provider starts consent when it mounts and stops it when it unmounts, so keep it at the root of the app.

ConsentBanner shows the banner when the visitor's policy asks for one. ConsentDialog is the preference dialog; it loads in its own chunk after the page, before anyone opens it. ConsentDialogLink is the persistent way back to preferences. Put it where visitors look for privacy settings, such as the footer.

There is no stylesheet to import. Each component adds the rules it uses to <head> when it first renders. With Tailwind CSS 3, import @c15t/svelte/styles.css yourself and pass styles={false} to the provider; see stylesheets and CSS layers.

Build and preview the production bundle with vite build and vite preview, then open the app in a private window with DevTools open on the Network tab:

  1. One /init request goes to your backend URL, starting before your JavaScript finishes downloading, and the banner appears. No requests go to PostHog yet.
  2. Select Reject All. Reload. The banner stays closed and PostHog requests stay absent.
  3. Select Privacy settings, turn on Analytics (the measurement category) and save. PostHog loads.
  4. Turn it off and save. The page reloads and PostHog does not load.

If the banner does not appear, check the /init request, then see troubleshooting. Verify consent has the release checklist.

Send /init early

consentManifest() adds a small inline script to the <head> of index.html. It sends /init while the browser downloads your JavaScript, and hosted() uses that response instead of sending its own. The banner usually shows 50 to 250 ms sooner. There is nothing to configure.

The early request can't read your app's options, so in a few setups it won't match and the app sends a second /init. Pass initPrefetch: false when the app uses offline() or gives hosted() its own backendURL, fetch, initURL or headers. With its own fetch, hosted() sends /init through it and ignores the early response. If it sets credentials, overrides, storageKey, an experiment, or journey: false, pass the same values as initPrefetch: { credentials, experiment, journey, overrides, storageKey }. With journey: 'tab', passing it too makes a tab's first /init report the 'tab' scope instead of 'page'. It doesn't save a request. Pass experiment only when c15t assigns the arm. If a feature flag picks it, leave it out, or the script sends an arm the visitor may never see. The dev server adds the script even to a manifest() app, which then sends one /init per page load that it doesn't use. A production build leaves it out.

Other setups

Offline mode resolves policies in the browser and keeps choices in the browser's cookie and local storage, with no backend and no consent records. Use it for local development and tests. Not recommended for production environments.

import { offline } from '@c15t/svelte';

const mode = offline();

With no arguments, offline() uses the recommended rules. They ask for opt-in in Europe and unknown locations, use opt-out in US privacy states, and show no banner where no consent law applies. Pass offline({ policyRules }) to use your own. Data fetching explains the trade-offs.

A self-hosted backend uses the same setup with your own backend URL. See self-hosting.

A bundled policy with manifest() saves the /init request in a few cases; see bundle the policy at build time.

Astro can render these Svelte components as islands. Its integration uses Svelte for the dialog by default. Pick the Astro row in choose your setup.

Next, customize the banner or read consent in your own components with context getters.

Bundle the policy at build time

manifest() resolves the policy in the browser from a snapshot consentManifest downloads during vite build. It saves the /init request only in these cases:

  • The policy has no location rules, or every location gets the same banner.
  • The page already knows the visitor's location, for example from an edge worker, and passes it as manifest({ inputs: { country, region } }).
  • You run a same-origin route that returns the location and set it as geoURL.

Otherwise the browser still calls /init on a first visit and uses the backend's answer, so the bundle adds bytes without saving a request. consentManifest warns during the build when that happens. The resolver adds about 3 KB gzipped, loaded as its own chunk, and the backend doesn't count visitors the browser resolves this way. Swap the mode in src/App.svelte:

src/App.svelte (partial)
<script lang="ts">
	import { ConsentProvider, manifest } from '@c15t/svelte';
</script>

<ConsentProvider mode={manifest()} {scripts}>

The bundle carries the policy and English copy. A visitor who resolves to another language loads that language's copy the first time it is needed.

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.

manifest({ source: 'runtime' }) fetches the manifest from the backend when the page loads, so policy edits apply without a rebuild. manifest({ manifestURL }) fetches it from another URL, such as a CDN, instead of using the build's snapshot.