Skip to main content

SvelteKit

Rendering and deployment

Pick a rendering path

c15tHandle() in src/hooks.server.ts holds the config for the whole app. Its mode option decides where each visitor's policy comes from. Import the modes from @c15t/svelte/kit: they are plain data, which loadConsent sends to the browser.

Your appWhere consent resolvesc15tHandle() options
Server-rendered pages, recommendedRoot layout load, from a build-time manifestNone. See the quickstart.
Policy changes must apply without a rebuildRoot layout load, from a manifest fetched at runtimemode: manifest({ source: 'runtime' }). See fetch the manifest at runtime.
Backend resolves every visitRoot layout load calls the backend's /initmode: hosted(). See ask the backend on every request.
A server app with some prerendered pagesServer for most pages, the browser on prerendered onesroutePrefix: '/api/c15t' and a consent route. See prerender some pages.
A fully static site with adapter-staticThe browsermode: hosted(). See build a static site.
SPA mode, ssr = falseThe browserSee use SPA mode.

Every path uses the same root layout: loadConsent as the server load and ConsentRoot with state={data.consent}. The quickstart's code is the examples/sveltekit app in the c15t repository. Data fetching compares /init, the manifest and browser resolution in general.

Bundle the manifest during builds

The build fetches your public policy once and bundles it, so the server never fetches it at runtime.

The quickstart sets this up across these files:

FileRole
vite.config.tsconsentManifest from @c15t/svelte/vite fetches the policy.
src/hooks.server.tsc15tHandle(), whose default mode, manifest(), resolves from the fetched policy.
src/routes/+layout.server.tsexport { loadConsent as load } resolves each visitor.
src/routes/+layout.svelteConsentRoot state={data.consent} renders the result.

The plugin's position in plugins does not matter. It fetches the policy once Vite has resolved its config, before anything compiles. Without a backendURL option, it reads PUBLIC_C15T_BACKEND_URL, then PUBLIC_INTH_PROJECT_URL, from the environment or .env.

The plugin detects the sveltekit() plugin and keeps the snapshot on the server. loadConsent and createConsentRoute() read it without an import. The browser bundle never holds it: ConsentRoot gets the resolved policy from loadConsent instead.

Static hosts follow build a static site instead.

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.

On SvelteKit 3, svelte-kit sync resolves the Vite config, so it runs the plugin and fetches the policy too, following the same rules when the fetch fails. SvelteKit 2.68 also runs the plugin during svelte-kit sync, but logs a failed fetch and continues.

To apply policy edits without a rebuild, set c15tHandle({ mode: manifest({ source: 'runtime' }) }) and follow fetch the manifest at runtime. To have the backend resolve every visit instead, set c15tHandle({ mode: hosted() }). The layout load then calls the backend's /init, and so does the browser on pages the server did not resolve.

Fetch the manifest at runtime

Use runtime manifest fetching when policy updates must apply without a rebuild. Your server downloads the project's public policy manifest, caches it, and resolves each visitor locally. Consent saves still go to the backend, directly or through the consent proxy.

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

export const handle = c15tHandle({ mode: manifest({ source: 'runtime' }) });

Keep consentManifest in vite.config.ts: it supplies the backend URL. Set consentManifest({ onBuildError: 'runtime' }) so a build does not depend on the backend, or pass c15tHandle({ backendURL }). loadConsent fetches ${backendURL}/manifest, or the URL in manifest({ manifestURL }).

The server caches the manifest in memory for as long as its s-maxage allows. After that, within the backend's stale-while-revalidate window, it serves the stale copy while it refreshes in the background. Past that window, the next request waits for a fresh copy, up to the 500 ms budget of loadConsent. It also reports each resolved visit to the backend so Inth still counts visitors it never served /init to.

Pass the absolute Inth URL as backendURL. A relative URL, such as /api/self-host for a backend mounted in your app, is fetched with event.fetch inside the app and is never resolved against the request's Host header. One that points back at the consent route makes the route fetch itself.

Ask the backend on every request

mode: hosted() sends every server render to the backend's /init, which resolves the visitor's location itself. The browser asks the same /init on pages the server did not resolve:

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

export const handle = c15tHandle({ mode: hosted() });

hosted() reads the backend URL from consentManifest, or takes hosted({ backendURL }). The request to /init carries the resolved location, language and GPC, the user-agent and, over https or to a loopback host, the consent cookie alone. Every render waits on the backend, up to 500 ms. consentManifest still downloads the manifest during the build; set onBuildError: 'runtime' if a backend outage should not stop the build.

A page the server resolved needs no route. The browser needs one only when it resolves consent itself, such as on a prerendered page, and you want it to ask your own origin instead of the backend. Add a catch-all route at src/routes/api/c15t/[...path]/+server.ts:

src/routes/api/c15t/[...path]/+server.ts
import { createConsentRoute } from '@c15t/svelte/kit';

// GET /api/c15t/init resolves the visitor from the bundled policy, and
// GET /api/c15t/manifest serves it. Other methods are not handled: the
// browser saves consent to the backend URL directly. Pair it with
// `c15tHandle({ routePrefix: '/api/c15t' })`.
export const { GET } = createConsentRoute();

Tell the handle where it is, so loadConsent passes the prefix to the browser:

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

export const handle = c15tHandle({ routePrefix: '/api/c15t' });

createConsentRoute() returns GET, which serves two paths:

RequestResponse
GET /api/c15t/initThe visitor's resolved policy, like the backend's /init. Cache-Control: private, no-store.
GET /api/c15t/manifestThe manifest, with the backend's Cache-Control and ETag. Answers If-None-Match with 304.

The route resolves from the snapshot consentManifest downloaded and reads the backend URL from it, so it needs no options. It also reads what you pass to c15tHandle(): a snapshot, a backendURL, and the mode's own backendURL or manifestURL. Set them once, on the handle. Options you pass to createConsentRoute() win.

This route file exports GET only, so a POST or PATCH to /api/c15t gets a 405. Consent saves go to the backend URL directly, unless you turn on the proxy described in save consent through your own origin.

To keep every consent request on your site's origin, pass proxy: true and export the write handlers from the same catch-all route:

src/routes/api/c15t/[...path]/+server.ts
import { createConsentRoute } from '@c15t/svelte/kit';

export const { GET, POST, PATCH, PUT, DELETE, OPTIONS } = createConsentRoute({
	proxy: true,
});

Then point saves at the route:

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

export const handle = c15tHandle({
	backendURL: '/api/c15t',
	routePrefix: '/api/c15t',
});

The route skips a handle backendURL that points at itself, and forwards to the backend URL consentManifest read from PUBLIC_C15T_BACKEND_URL or PUBLIC_INTH_PROJECT_URL. To forward somewhere else, pass createConsentRoute({ backendURL, proxy: true }). With the proxy on, GET still resolves init and manifest in the process, and the handlers forward other requests to backendURL. They forward only subjects, subjects/:id, init, manifest, health, status and any extra paths you list in proxy: { paths }, and answer anything else with 404. Cookies go upstream only when proxy: { cookieNames } names them. The visitor's address comes from event.getClientAddress(), and x-forwarded-host and x-forwarded-proto come from event.url, so configure your adapter's address header if it runs behind a proxy. The proxy forwards to backendURL, never to a manifestURL. An absolute URL is fetched over the network. A relative one, such as a route elsewhere in your app, is fetched with event.fetch inside the app and is never resolved against the request's Host header.

Keep background work alive after the response

The route and loadConsent use only fetch, so they run on Node and edge adapters alike. Some runtimes stop work once a response is sent, which would cut off the background manifest refresh and the visit report. When the adapter exposes event.platform.context.waitUntil, c15t registers that work there automatically. Netlify does, as do Cloudflare and Vercel edge on SvelteKit 2.

SvelteKit 3's Cloudflare adapter no longer puts waitUntil on event.platform, and its Vercel adapter has no edge runtime. On those, pass the platform's own waitUntil as onBackgroundRevalidate:

src/routes/api/c15t/[...path]/+server.ts (partial)
import { createConsentRoute } from '@c15t/svelte/kit';
import { waitUntil } from 'cloudflare:workers'; // Vercel: '@vercel/functions'

export const { GET } = createConsentRoute({
	onBackgroundRevalidate: (promise) => waitUntil(promise),
});

Without it, those runtimes can cut the refresh and the visit report short. Requests keep getting the cached manifest, but it can stay stale for longer, and the backend misses those visits.

loadConsent takes the same option when you call it from your own load instead of exporting it directly:

src/routes/+layout.server.ts
import { loadConsent } from '@c15t/svelte/kit';
import { waitUntil } from 'cloudflare:workers';

export const load = (event) =>
	loadConsent(event, {
		onBackgroundRevalidate: (promise) => waitUntil(promise),
	});

loadConsent uses event.platform.context.waitUntil when available and this callback otherwise. The framework-free resolveConsent helper also accepts onBackgroundRevalidate; it has no event from which to find a platform hook.

Resolve request context once with c15tHandle

The quickstart installs c15tHandle in src/hooks.server.ts. It holds the consent config for the app and reads the consent cookie and headers once per request for every load and endpoint:

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

export const handle = c15tHandle();

It stores the result on event.locals.c15t, which loadConsent reuses, and rewrites the location headers into one normalized form. Compose it with your own handles through sequence(c15tHandle(), yourHandle) from @sveltejs/kit/hooks.

OptionDefaultWhat it does
modemanifest()How loadConsent resolves the visitor: manifest(), hosted() or offline() from @c15t/svelte/kit.
routePrefixnoneWhere createConsentRoute() is mounted, such as /api/c15t. Without it, the browser asks the backend's /init when it resolves consent itself.
backendURLThe URL consentManifest read from PUBLIC_C15T_BACKEND_URL or PUBLIC_INTH_PROJECT_URLThe backend. hosted({ backendURL }) wins.
snapshotThe manifest consentManifest downloadedA manifest of your own to resolve with.
cookieNamec15tThe consent cookie. Match the provider's storageConfig.storageKey.
country, region, languagefrom headersForces one input for every request.

c15tHandle also writes a server-rendered banner's rules into the HTML <head> as <style> elements, so the banner is styled before hydration. Without it, a server-rendered banner shows unstyled until the page hydrates, unless ConsentRoot sets styles={false} and the app imports @c15t/svelte/styles.css.

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

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

Options passed to one loadConsent call, such as country, override the handle's values for that call. Without the handle, loadConsent resolves with manifest() and reads the request itself.

Prerender some pages

A prerendered page is built once and sent to every visitor, so it cannot contain one visitor's consent. SvelteKit runs the root layout load while it builds those pages. loadConsent and c15tHandle read SvelteKit's building flag, so they return no visitor's consent and no policy then, and make no backend request. Prerendering a page needs only the page option:

src/routes/about/+page.ts
// Build this page once as static HTML. While SvelteKit prerenders,
// `loadConsent` returns no visitor's consent, so the browser resolves it
// here.
export const prerender = true;

On a prerendered page the HTML has no banner. After hydration the browser resolves the policy, then shows the banner or applies the stored choice. Optional categories stay denied until then. Server-rendered pages in the same app keep the banner in their HTML.

Where the browser asks depends on the handle:

Build a static site

With adapter-static, every page is prerendered and no server runs after the build. Set the option in the root layout:

src/routes/+layout.ts
export const prerender = true;

The browser resolves consent on every page. Server routes such as /api/c15t do not exist after a static build, so it has to ask the backend directly. Starting from the quickstart, set hosted() as the mode and keep the rest:

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

export const handle = c15tHandle({ mode: hosted() });

The hook and loadConsent still run while SvelteKit prerenders, and return no visitor's consent. The browser then calls the backend's /init, and the backend resolves the visitor's location. Leave out routePrefix and the consent route. On a static host, the backend URL must be the absolute Inth URL, and the backend must allow your site's origin.

Use SPA mode

With export const ssr = false in the root +layout.ts and a server adapter, the quickstart works unchanged. The root layout's server load still runs for each navigation, so ConsentRoot starts from a resolved policy; there is no server HTML, so the banner appears once the page has rendered in the browser.

With a static SPA fallback page and no server, no server load runs. Delete +layout.server.ts and the c15t handle, and render ConsentProvider with a browser mode in +layout.svelte:

src/routes/+layout.svelte (partial)
<script lang="ts">
	import { ConsentProvider, hosted } from '@c15t/svelte';
	import { PUBLIC_C15T_BACKEND_URL } from '$env/static/public';

	const mode = hosted({ backendURL: PUBLIC_C15T_BACKEND_URL });
</script>

<ConsentProvider {mode} {scripts}>

The browser resolves consent through the backend's /init. ConsentProvider takes the same props as ConsentRoot, with mode in place of state.

Taps before hydration

A banner in the server HTML shows before the page's JavaScript runs. A tap on it in that gap does nothing: the button has no handler yet, so the banner stays up and no choice is saved. The React and Vue banners hold such a tap and replay it after hydration; the SvelteKit banner does not.

resolveConsent from @c15t/svelte/server does what loadConsent does without a SvelteKit event, for endpoints or code outside a load:

import { resolveConsent } from '@c15t/svelte/server';

const state = await resolveConsent({
	backendURL: 'https://your-project.inth.app',
	headers: request.headers,
});

It never throws. When the backend fails or takes longer than timeoutMs, 500 ms by default, it returns the stored choice without a policy. Pass manifest to resolve from a manifest you hold instead of calling /init. In a load, use loadConsent, which reads the request and the handle's config from the event. Import @c15t/svelte/kit and @c15t/svelte/server only from server files such as +layout.server.ts, +server.ts and hooks.server.ts; imported into a component they add server code to the browser bundle.