Skip to main content

SvelteKit

Quickstart

Before you start

This guide sets up a server-rendered SvelteKit app. The root layout's server load resolves each visitor's consent before the page renders, so the banner is part of the first HTML and a returning visitor's choice applies from the first paint. For prerendered pages, a static site or SPA mode, follow this guide and then rendering and deployment. The examples/sveltekit app in the c15t repository is the finished result.

@c15t/svelte supports SvelteKit 2.63 and later, including SvelteKit 3, with Svelte 5. The examples use SvelteKit 3. On SvelteKit 2, keep the adapter in svelte.config.js instead of passing it to sveltekit().

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. The build downloads the policy from it and the browser saves choices to it.

A self-hosted backend works the same way with your own URL; see self-hosting.

The setup bundles your policy into the server at build time, the recommended production setup. Rebuild after changing policies, translations or vendors.

Install

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

SvelteKit uses @c15t/svelte: components from @c15t/svelte, server helpers from @c15t/svelte/kit and the Vite plugin from @c15t/svelte/vite. The c15t package has no Svelte entry point, so you don't install it.

Set the backend URL

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

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

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

The example app's .env points at https://example-inth.inth.app, a demo project. The build reads the variable, and the server and the browser use the value the build read, so rebuild after you change it.

Bundle the policy at build time

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

vite.config.ts
import { consentManifest } from '@c15t/svelte/vite';
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

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

consentManifest downloads your project's public policy from <backendURL>/manifest during vite build, and in vite dev the first time the server loads it. The policy stays on the server: the browser bundle never gets it. No file is written into your project. In a build, the plugin also lets c15tHandle, added below, preload the script loader on pages that register scripts.

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.

To apply policy edits without a rebuild, fetch the policy at runtime instead; see fetch the manifest at runtime.

Add the server hook

c15tHandle holds the consent config and reads the consent cookie, location headers and Global Privacy Control once per request. It stores them on event.locals.c15t for loadConsent and any other load or endpoint that needs them. It also writes a server-rendered banner's rules into the HTML <head> as <style> elements, so the banner is styled before hydration without a stylesheet request. Add it to src/hooks.server.ts:

src/hooks.server.ts
import { c15tHandle } from '@c15t/svelte/kit';

export const handle = c15tHandle();

With no options, the mode is manifest(): the server resolves each visitor from the bundled policy. Rendering and deployment covers hosted(), offline() and the other options.

Type event.locals.c15t with one line in src/app.d.ts:

src/app.d.ts
/// <reference types="@c15t/svelte/kit/locals" />

Export loadConsent as the root layout's server load:

src/routes/+layout.server.ts
export { loadConsent as load } from '@c15t/svelte/kit';

loadConsent resolves the visitor's policy from the bundled snapshot with the inputs c15tHandle read, and returns { consent }, a plain, serializable object for ConsentRoot. Rendering a page makes no policy request to the backend. loadConsent never throws. If resolution takes longer than 500 ms, it returns the stored choice without a policy, and the browser resolves the policy after hydration.

While SvelteKit prerenders pages at build time, loadConsent returns no visitor's consent and no policy, because a prerendered page goes to every visitor. It reads SvelteKit's building flag itself. Prerender some pages covers how the browser resolves consent there.

Render ConsentRoot in the root layout

src/routes/+layout.svelte
<script lang="ts">
	import { posthog } from '@c15t/integrations/posthog';
	import {
		ConsentBanner,
		ConsentDialog,
		ConsentDialogLink,
		ConsentRoot,
	} from '@c15t/svelte';

	let { children, data } = $props();

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

<ConsentRoot state={data.consent} {scripts}>
	{@render children()}
	<footer>
		<ConsentDialogLink>Privacy settings</ConsentDialogLink>
	</footer>
	<ConsentBanner />
	<ConsentDialog />
</ConsentRoot>

Pass data.consent through unchanged. ConsentRoot starts from it, so the server and the browser render the same banner and the browser makes no second request for the policy. A page the server resolved ships no resolver, policy rules or snapshot to the browser.

PostHog waits for measurement permission. Replace phc_your_project_key with your PostHog project key. Remove any <script> tags in src/app.html or SDK imports that already load PostHog, so c15t is the only thing that loads it. Create scripts in the layout component, not in +layout.server.ts: they hold functions, which a server load cannot send to the browser.

ConsentBanner shows the banner when the 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: the components bring their own rules, and the server hook writes the banner's into the HTML.

Keep ConsentRoot in the root layout so it stays mounted across client-side navigation.

Build the app with vite build and serve the production build with vite preview, then open it in a private window with DevTools open:

  1. View the page source. It contains data-testid="consent-banner-root", so the banner is in the server HTML, and <head> holds a <style data-c15t-styles> element with its rules.
  2. In the Network panel, no requests go to PostHog.
  3. Select Reject All and reload. The page source no longer contains the banner, and PostHog requests stay absent.
  4. Select Privacy settings, turn on Analytics (the measurement category) and save. PostHog loads.
  5. Turn it off and save. The page reloads and PostHog does not load.
  6. Navigate to another route and back. The choice holds without a new banner.

If the build fails or the banner is missing from the page source, see troubleshooting. Verify consent has the release checklist.

Next steps