You moved a vendor tag into <svelte:head> because that is where scripts belong, and now the vendor hostname appears in every server-rendered response — including for visitors who have not answered the consent banner.
Triage: Confirm It Is in the Response
curl -s http://localhost:5173/ | grep -c 'vendor.example'against a production build, not the dev server.- Repeat for a route rendered by a different layout, and for one with
ssr = falseif you have any. - In the browser, load with consent pending and filter the Network panel to the vendor origin.
- Confirm where the tag is declared —
<svelte:head>in a component, orapp.html.
Root Cause: The Head Block Is Server Markup
<svelte:head> is a compile-time instruction that relocates its contents into the document head. During server rendering it is evaluated like any other part of the template, and its output is serialised into the response — which is exactly what you want for a meta tag or a stylesheet, and exactly wrong for a script that requires permission.
The consequence is the same as in every server-rendered framework: the browser receives markup naming the vendor, discovers the URL through the preload scanner, and fetches it before any client code has had the opportunity to decide. Removing the element afterwards changes the DOM and not the network.
What makes this particularly easy to get wrong in SvelteKit is that onMount — the correct place for a gated injection — never runs on the server by specification, so the framework does provide a clean mechanism. The trap is that head blocks feel like the idiomatic place for a script tag, and they are, for scripts that are not conditional.
Resolution: Hints in the Head, Scripts in onMount
<script>
import { onMount } from 'svelte';
import { consent, allows } from '$lib/consent.js';
import { loadScript } from '$lib/load-script.js';
import { VENDORS } from '$lib/vendors.js';
const analyticsAllowed = allows('analytics');
onMount(() => {
// Never runs on the server, by specification — so nothing can leak.
const stop = analyticsAllowed.subscribe((ok) => {
if (!ok) return;
loadScript(VENDORS.analytics.src, { integrity: VENDORS.analytics.integrity })
.catch((e) => report('vendor.load.failed', { src: VENDORS.analytics.src, e }));
});
return stop;
});
</script>
<svelte:head>
<!-- Safe: no payload, no consent question, and it warms the socket while
the visitor is reading the banner. -->
<link rel="preconnect" href={VENDORS.analytics.origin} crossorigin />
</svelte:head>
The division is worth stating as a rule because it generalises: the head carries statements about origins, and code carries decisions about vendors. A preconnect is a statement — it says you may contact this origin, which your privacy notice already discloses. A <script src> is a decision, and decisions that depend on consent cannot be made at build or render time.
Keeping the loaded map at module scope in load-script.js is the other necessary piece. A layout that remounts during client-side navigation will re-run onMount, and without the map that is a second injection of the same vendor.
Verification: Grep, Then Exercise
Build for production, serve it, and grep the response for every vendor hostname. Zero matches on every route. Then load with consent pending and confirm no vendor request; accept and confirm the request appears.
Keeping the Property After the Fix
The correction is a one-off; keeping it is not. A consent leak of this kind is invisible in every ordinary check — the site builds, renders, and behaves correctly — so the only thing that prevents its return is an assertion that runs without anyone remembering to look.
Add one integration test that renders a route through the server pipeline, takes the resulting HTML string, and asserts that no vendor hostname appears in it. It runs in milliseconds, needs no browser, and fails the moment someone reintroduces a tag into markup for a reason that seemed sensible at the time.
Generate the hostname list from the vendor registry rather than hard-coding it, so a vendor added next quarter is covered automatically. That closes the loop the same way it does everywhere else in this area: one source of truth, several consumers, and no opportunity for the list the test checks to drift from the list the site loads.
What Belongs in app.html
app.html is the template for every response, which makes it the most consequential file in this discussion and the one with the least review attention. Anything placed there ships to every visitor on every route, before hydration, unconditionally — a property that is exactly right for two things and wrong for everything else.
The two are a consent platform’s own stub, which must exist before any container reads it, and an anti-flicker snippet whose entire purpose is to hold the paint until a personalisation decision resolves. Both are strictly necessary in the sense the regulations use, both are small, and both are broken by any deferral.
Everything else that ends up there does so by accident or by convenience: a vendor snippet pasted in during an incident, a font preload for a font one route uses, an analytics tag added before the consent work existed. Auditing that file periodically — and requiring a justification comment beside each entry — is a small piece of process that prevents the single most expensive category of leak, because a leak there affects every page rather than one.
Common Pitfalls
-
Putting a gated script in
svelte:head. It is server markup; the consent check cannot reach it. -
Testing against the dev server. Development rendering differs enough that a leak can be absent locally and present in production.
-
Declaring the deduplication map inside the component. Each instance gets a fresh one, so navigation duplicates the script.
-
Putting the vendor URL in
app.html. Every response carries it, for every route, unconditionally. -
Assuming the head block is client-only because it manipulates the head. It participates in the server render exactly as any other part of the template does; where the content ends up in the document says nothing about when it was produced.
-
Reading the consent cookie only on the client. That works, and it gives up the benefit — reading it in a server
loadfunction is what lets a returning visitor’s first paint already reflect their decision, rather than being corrected after hydration.
Frequently Asked Questions
Is svelte:head ever right for a third-party script?
For a strictly necessary one, yes. A consent platform’s own stub, or an anti-flicker snippet whose purpose is to hold the paint, must run before anything else and requires no permission — both belong in the head, and deferring them defeats their purpose.
Everything else is conditional on something, and conditions cannot be evaluated at render time for state that only exists in the browser. The test is simple: if there is any circumstance in which this script should not load, it does not belong in server-rendered markup.
Does the head block deduplicate across components?
Yes — content is deduplicated by its key, so two components declaring the same preconnect produce one element rather than two. That is convenient for hints declared alongside the components that need them, and it means you can put a hint in a component without worrying about a layout also declaring it.
Do not rely on it for scripts. Deduplication happens at render time and says nothing about a script injected at runtime, which is why the loaded map exists in the loader rather than being handled by the framework.
What about scripts that must run before hydration?
Those go in app.html, and the list of them should be very short. Anything in that file is present in every response for every route unconditionally, which is exactly the property a consent default needs and exactly the property a vendor tag must not have.
If a vendor claims to need pre-hydration execution, ask what specifically breaks otherwise. The requirement is usually about ordering relative to their other tags rather than relative to your hydration, and those are very different asks — the first is satisfied by document order, the second by delaying interactivity for every visitor.
Does this differ for a prerendered route?
The hazard is worse, not better. A prerendered route is generated once at build time and served identically to everyone, so a tag in its head block is baked into a static file with no request-time decision available at all — the same situation as a statically built Astro page, and for the same reason.
The remedy is identical because it does not depend on rendering mode: keep the vendor URL in code that runs only in the browser. An onMount injection works the same way on a prerendered route as on a server-rendered one, which is one of the practical advantages of putting the decision in code rather than in markup.