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

  1. curl -s http://localhost:5173/ | grep -c 'vendor.example' against a production build, not the dev server.
  2. Repeat for a route rendered by a different layout, and for one with ssr = false if you have any.
  3. In the browser, load with consent pending and filter the Network panel to the vendor origin.
  4. Confirm where the tag is declared — <svelte:head> in a component, or app.html.
What svelte:head does and does not do The svelte:head block moves its contents into the document head, and it participates in server rendering — so anything placed there appears in the server response exactly as if it had been written in the template. It is a placement mechanism, not a client-only one, which is the distinction the leak depends on. what it does moves content into the head deduplicates by key the right tool for hints what it does not do it does not defer to the client it renders on the server the wrong tool for gated tags A placement mechanism, not a gate. The contents are serialised like any other markup.

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.

Where each kind of tag belongs Three kinds of head content and where each belongs. A connection hint such as preconnect transfers no payload and belongs in the head block, where it is discovered as early as possible. A strictly necessary script with no consent requirement also belongs there. A consent-gated vendor script belongs in an onMount injection, because it must not appear in the server response at all. preconnect / dns-prefetch svelte:head — no payload, no consent question, earliest discovery strictly necessary script svelte:head — it runs for everyone regardless consent-gated vendor onMount injection — must not reach the server response The first row is the useful thing to keep in the head. It costs nothing and shortens the post-consent path.

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.

Three states to check Three checks. The production build response must contain no vendor hostname on any route. A page loaded with consent pending must produce no vendor request. And after several client-side navigations there must be exactly one request, confirming the deduplication map is at module scope rather than per component instance. built response zero hostname matches every route consent pending no vendor request the gate holds after navigations exactly one request the map is module-scoped The third catches a deduplication map declared inside the component rather than beside it.

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 load function 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.


Up: Isolating Third-Party Scripts in SvelteKit