---
title: Rendering and deployment
description: Use the recommended build-time manifest for SvelteKit server
  rendering, choose runtime fetching for policy updates without a rebuild, and
  handle prerendered pages, static sites and SPA mode.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## 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 app|Where consent resolves|`c15tHandle()` options|
|--|--|--|
|Server-rendered pages, recommended|Root layout load, from a build-time manifest|None. See the [quickstart](/docs/frameworks/sveltekit/quickstart).|
|Policy changes must apply without a rebuild|Root layout load, from a manifest fetched at runtime|`mode: manifest({ source: 'runtime' })`. See [fetch the manifest at runtime](#fetch-the-manifest-at-runtime).|
|Backend resolves every visit|Root layout load calls the backend's `/init`|`mode: hosted()`. See [ask the backend on every request](#ask-the-backend-on-every-request).|
|A server app with some prerendered pages|Server for most pages, the browser on prerendered ones|`routePrefix: '/api/c15t'` and a consent route. See [prerender some pages](#prerender-some-pages).|
|A fully static site with `adapter-static`|The browser|`mode: hosted()`. See [build a static site](#build-a-static-site).|
|SPA mode, `ssr = false`|The browser|See [use SPA mode](#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](/docs/concepts/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](/docs/frameworks/sveltekit/quickstart) sets this up across
these files:

|File|Role|
|--|--|
|`vite.config.ts`|`consentManifest` from `@c15t/svelte/vite` fetches the policy.|
|`src/hooks.server.ts`|`c15tHandle()`, whose default mode, `manifest()`, resolves from the fetched policy.|
|`src/routes/+layout.server.ts`|`export { loadConsent as load }` resolves each visitor.|
|`src/routes/+layout.svelte`|`ConsentRoot 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](/docs/frameworks/sveltekit/rendering#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](/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.

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](/docs/frameworks/sveltekit/rendering#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](#save-consent-through-your-own-origin).

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

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

## Serve the consent route

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`:

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

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

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

`createConsentRoute()` returns `GET`, which serves two paths:

|Request|Response|
|--|--|
|`GET /api/c15t/init`|The visitor's resolved policy, like the backend's `/init`. `Cache-Control: private, no-store`.|
|`GET /api/c15t/manifest`|The 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](#save-consent-through-your-own-origin).

### 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:

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

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

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

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

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

|Option|Default|What it does|
|--|--|--|
|`mode`|`manifest()`|How `loadConsent` resolves the visitor: `manifest()`, `hosted()` or `offline()` from `@c15t/svelte/kit`.|
|`routePrefix`|none|Where [`createConsentRoute()`](#serve-the-consent-route) is mounted, such as `/api/c15t`. Without it, the browser asks the backend's `/init` when it resolves consent itself.|
|`backendURL`|The URL `consentManifest` read from `PUBLIC_C15T_BACKEND_URL` or `PUBLIC_INTH_PROJECT_URL`|The backend. `hosted({ backendURL })` wins.|
|`snapshot`|The manifest `consentManifest` downloaded|A manifest of your own to resolve with.|
|`cookieName`|`c15t`|The consent cookie. Match the provider's `storageConfig.storageKey`.|
|`country`, `region`, `language`|from headers|Forces 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`:

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

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

* With [`routePrefix` and the consent route](#serve-the-consent-route), it
  asks your own `/api/c15t/init`, which resolves from the bundled snapshot.
* Without them, it asks the backend's `/init`.

## 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:

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

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

```svelte title="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.

## Resolve consent in other server code

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

```ts
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.
