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 app | Where consent resolves | c15tHandle() options |
|---|---|---|
| Server-rendered pages, recommended | Root layout load, from a build-time manifest | None. See the 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. |
| Backend resolves every visit | Root layout load calls the backend's /init | mode: hosted(). See 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. |
A fully static site with adapter-static | The browser | mode: hosted(). See build a static site. |
SPA mode, ssr = false | The browser | See 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:
| 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 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:
| 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:
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.
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:
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:
Tell the handle where it is, so loadConsent passes the prefix to the
browser:
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
To keep every consent request on your site's origin, pass proxy: true and
export the write handlers from the same catch-all route:
Then point saves at the route:
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:
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:
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:
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() 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:
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:
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
routePrefixand 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:
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:
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:
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:
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.