Skip to main content

SvelteKit Advanced

Geography headers

Why location matters

Your consent policy picks a rule by the visitor's country and region: opt-in in Europe, opt-out in US states with privacy laws, no banner where no law applies. On SvelteKit, the server helpers read that location from request headers your host or CDN adds. If no header arrives, the policy resolves with its rule for an unknown location.

Which headers are read

loadConsent, c15tHandle, the manifest route and resolveConsent read the same headers, in this order. The first one present wins.

InputHeaders, highest priority first
Countryx-c15t-country, cf-ipcountry, x-vercel-ip-country, x-amz-cf-ipcountry, x-country-code, x-country
Regionx-c15t-region, cf-region-code, x-vercel-ip-country-region, x-region-code
Languageaccept-language, negotiated by quality value to a primary tag such as de
Global Privacy Controlx-c15t-gpc, sec-gpc; only 1 and 0 count

Vercel adds its country and region headers on every request. Cloudflare adds cf-ipcountry; for cf-region-code, turn on Cloudflare's visitor location headers. On another host, or a Node server without a CDN, set x-c15t-country and x-c15t-region at your proxy from whatever location data it has.

With a build-time snapshot, as in the quickstart, loadConsent resolves the policy in the server process from these inputs and makes no policy request. In hosted() mode, it sends the resolved inputs to the backend's /init as x-c15t-country, x-c15t-region, accept-language and sec-gpc, along with user-agent. It adds the visitor IP as x-forwarded-for only with trustForwardedHeaders: true. It never forwards forwarded, x-forwarded-host or x-forwarded-proto; with that option it reads them to resolve a relative backendURL. When the backend is a route in your own app, it passes the same headers through event.fetch, which forwards only cookies and authorization by itself.

Resolve the inputs once per request

c15tHandle in src/hooks.server.ts reads the headers once, stores the inputs on event.locals.c15t.inputs, and rewrites the request's headers to the normalized x-c15t-country, x-c15t-region, accept-language and sec-gpc. Later loads, endpoints and proxied backend calls then see one form, whichever CDN header carried the value:

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

export const handle = c15tHandle();

Server API lists its options.

Trust the headers

The x-c15t-* headers always win, and a browser can send them. A visitor who sets x-c15t-country: US gets the policy for the United States. That changes only their own banner, not anyone else's, but if you need the policy to follow your CDN's location, remove incoming x-c15t-* headers at your edge before the request reaches SvelteKit.

cf-ipcountry and the Vercel headers are set by the platform and cannot be forged through it. Behind your own proxy, make sure it overwrites the country headers rather than passing a client's value through.

The consent route fetches a relative backendURL through event.fetch and never resolves it against the request, so a forged Host or x-forwarded-host header cannot send its requests elsewhere. loadConsent resolves a relative backendURL against event.url. With its default event.fetch, the call stays in-process. With your own fetch, it goes to the origin of event.url. adapter-node builds that origin from paths.origin in SvelteKit 3 (the ORIGIN variable in SvelteKit 2), or from its PROTOCOL_HEADER and HOST_HEADER settings; with none of them, it takes the host from the Host header. SvelteKit 3's adapter-node ignores ORIGIN without a warning, so move it to paths.origin when you upgrade. A forged x-forwarded-host changes neither unless you set trustForwardedHeaders. See Server API.

Force a location

Pass country, region or language to force an input:

  • On c15tHandle({ country: 'DE' }), for every request, such as a site that serves one country.
  • On one loadConsent(event, { country: 'DE' }) call, from a layout's own load, such as a /de/ section.

A per-call value wins over the handle's. The browser's provider takes overrides={{ country, region, language, gpc }} for the same purpose after hydration.

Test another location

Send the override header with a request and look for the banner in the HTML:

curl -s -H 'x-c15t-country: DE' http://localhost:5173/ | grep -c consent-banner-root
curl -s -H 'x-c15t-country: US' -H 'x-c15t-region: CA' http://localhost:5173/ | grep -c consent-banner-root
curl -s -H 'Sec-GPC: 1' -H 'x-c15t-country: US' -H 'x-c15t-region: CA' http://localhost:5173/

The first request prints 1 under a policy that asks Europe for opt-in. What the others print depends on your policy's rules for California. In the browser, the dev tools panel's location tab changes the location for the current page without a request header.

Verify

  1. On your deployed site, open DevTools and check the document request's headers for your CDN's country header.
  2. View the page source. The banner is in the HTML for a location that needs one, and absent for one that does not.
  3. With the dev tools panel, confirm the resolved country and policy match where you are.