SvelteKit ships no equivalent of the Next.js Script component. There is no strategy prop, no built-in worker offload, and no framework-blessed place to put a vendor tag — which means the loading policy is entirely yours to define. That is more work than the alternative and, once defined, considerably more predictable: nothing is happening except what you wrote.

The framework does provide the three primitives the job needs. The browser constant from $app/environment tells you which side of the render you are on. Svelte stores give you reactive consent state that components can subscribe to without prop-drilling. And onMount gives you a lifecycle hook that, by specification, never runs during server rendering — which turns “do not leak this tag into the HTML” from a discipline into a property of where the code lives.

This guide builds a small loader from those three, gates it on consent, and covers the SvelteKit-specific hazards: the shared module scope across requests on the server, and what happens to injected scripts across client-side navigations.


Prerequisites and When to Apply

Use this pattern when you load any third-party script that is not strictly necessary — analytics, marketing, chat, embeds — and particularly when you server-render, which is SvelteKit’s default. A statically prerendered site with no server-rendered pages has a narrower problem, but the injection discipline still applies because the tag is still in the HTML.

The pattern assumes a resolved consent decision is available client-side, from whatever consent platform you use. It does not assume any particular platform: the store described below is an adapter boundary, and swapping the platform behind it touches one file.

If you are not gating on consent at all — because every vendor you load is strictly necessary, which is rare — the loading half still stands on its own, and the store simply always reports granted.

Three primitives, three guarantees The browser constant from app environment tells code which side of the render it is on, so a module can refuse to act on the server. A Svelte store holds the consent decision reactively, so any component can subscribe without prop drilling and every subscriber sees the same value. The onMount lifecycle hook never runs during server rendering by specification, so anything injected from it cannot reach the initial HTML. Together they cover placement, state and timing. browser $app/environment which side of the render this code is executing on guarantees: placement a module can refuse to act writable() store svelte/store one reactive consent value any component may subscribe guarantees: one state no prop drilling, no drift onMount() svelte never runs during SSR, by specification guarantees: timing nothing reaches the initial HTML The third is the strongest: it makes the leak impossible by construction rather than by a guard you might forget. Prefer it to a browser check wherever the whole operation belongs on the client.

Concept: Module Scope Is Shared Across Requests

One SvelteKit behaviour deserves stating before any code, because it produces a class of bug that is invisible in development and severe in production: module-level state on the server is shared between requests. A module is evaluated once per server process, not once per request, so a top-level let consent = null is a variable that every visitor shares.

In development, with one request at a time, this looks like it works. Under production concurrency it means one visitor’s consent decision can be observed by another visitor’s render — which is both a correctness bug and a data protection incident.

The rule that follows is simple and absolute: anything per-visitor lives in a store created per request, or on the client only. For consent specifically, the client-only option is the right one, because the decision cannot be known on the server anyway unless it has been forwarded in a cookie.

Module state on the server is shared; client state is not On the server, one module instance is evaluated per process, so two concurrent visitors read and write the same top-level variable — visitor A's consent decision is visible during visitor B's render. On the client, each visitor has their own JavaScript context, so the same module is a separate instance per visitor and no sharing is possible. A note records that this bug is invisible in development, where requests are handled one at a time. server · one module per process let consent visitor A visitor B both read and write the same variable client · one context per visitor let consent let consent visitor A's copy visitor B's copy separate by construction — no discipline required Development serves one request at a time, so the left-hand box behaves like the right-hand one until it does not. Load-test with concurrency before trusting any server-side per-visitor state.

Implementation

// src/lib/consent.js
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';

// 'pending' is a distinct state from 'denied'. A two-valued flag cannot express
// "not asked yet", and code that treats them alike leaks during the gap.
export const consent = writable({ analytics: 'pending', marketing: 'pending' });

export const allows = (category) =>
  derived(consent, ($c) => $c[category] === 'granted');

export function initConsent() {
  if (!browser) return;              // never subscribe to a platform on the server
  window.__cmp?.addEventListener?.('change', (state) => {
    consent.set({
      analytics: state.statistics ? 'granted' : 'denied',
      marketing: state.marketing ? 'granted' : 'denied',
    });
  });
}

Step 2 — A loader that injects once and reports failure

// src/lib/load-script.js
const loaded = new Map();   // src → Promise, so concurrent callers share one load

export function loadScript(src, { integrity, crossOrigin = 'anonymous' } = {}) {
  if (loaded.has(src)) return loaded.get(src);

  const p = new Promise((resolve, reject) => {
    const el = document.createElement('script');
    el.src = src;
    el.async = true;
    // Injected scripts default to Low priority — this one is now user-relevant.
    el.fetchPriority = 'high';
    el.crossOrigin = crossOrigin;
    if (integrity) el.integrity = integrity;   // set before append, like fetchPriority
    el.onload = () => resolve(el);
    el.onerror = () => {
      loaded.delete(src);                      // allow a retry on a later navigation
      reject(new Error(`failed to load ${src}`));
    };
    document.head.appendChild(el);
  });

  loaded.set(src, p);
  return p;
}

Step 3 — Wire it up in the root layout

<!-- src/routes/+layout.svelte -->
<script>
  import { onMount } from 'svelte';
  import { consent, allows, initConsent } from '$lib/consent.js';
  import { loadScript } from '$lib/load-script.js';
  import { VENDORS } from '$lib/vendors.js';

  const analyticsAllowed = allows('analytics');

  onMount(() => {
    initConsent();

    // Subscribe rather than read once: the decision usually arrives after mount.
    const stop = analyticsAllowed.subscribe((ok) => {
      if (!ok) return;
      loadScript(VENDORS.analytics.src, { integrity: VENDORS.analytics.integrity })
        .catch((err) => report('vendor.load.failed', { src: VENDORS.analytics.src, err }));
    });

    return stop;   // onMount's return value is the cleanup — unsubscribe on unmount
  });
</script>

<slot />

Three details make this correct rather than merely working. The subscription is inside onMount, so the whole block is client-only by construction. The loaded map means a second navigation that re-runs the layout does not inject a second copy. And returning stop from onMount unsubscribes on teardown, which matters in a long single-page session where layouts can remount.

What survives a client-side navigation Across a client-side navigation, the injected script element and everything it defined persist, because the document is never replaced. The component that injected it may unmount and remount, so the injection code runs again and is prevented from duplicating only by the loaded map. The consent subscription is torn down and re-established. A note records that a vendor requiring per-view initialisation needs an explicit call on navigation, since the script itself will not re-execute. client-side navigation route A route B the injected script element and its globals — persist throughout component instance + subscription a new instance, a new subscription, injection re-attempted The loaded map is the only thing preventing a second copy on the right-hand side. A vendor needing a per-view call must be told explicitly — the script will not re-execute. Hook that call to afterNavigate rather than to the component's own mount.

Where to Put the Loader in a Real Application

The layout example above is deliberately minimal. A production application has more surfaces than one layout, and the placement question — which of them owns the vendor lifecycle — is worth settling once rather than per vendor.

Site-wide vendors belong in the root layout. Analytics, error reporting, and anything expected on every page should be loaded from +layout.svelte at the root, because that component mounts once and stays mounted across client-side navigations. Loading them lower down means they are torn down and re-established on navigation, which achieves nothing and risks duplication.

Route-scoped vendors belong in the route’s own component, and need an explicit decision about teardown. Because the injected script persists after its component unmounts, a vendor loaded on one route is present for the rest of the session unless you stop it deliberately. Where that is unacceptable — a heavy widget you genuinely want gone when the visitor leaves — the loader is the wrong tool and the vendor should live inside something disposable, such as an iframe you unmount or a worker you terminate.

The consent subscription belongs in exactly one place. It is tempting to have each vendor’s component subscribe independently, and it works, but it means several subscriptions racing to react to the same change and no single point at which you can log what happened. One subscription in the root layout, dispatching to a registry of vendors, is easier to reason about and gives revocation somewhere obvious to live.

That registry is worth building even for two vendors, because it is the same artefact the rest of this site’s practices consume. A single vendors.js holding, per vendor, the source URL, the integrity digest, the consent category and the teardown call is what lets one loop handle loading, one loop handle revocation, and a build step generate the content security policy allowlist from the same source. Scattering those four facts across four files is how they drift apart.

// src/lib/vendors.js — one record, several consumers.
export const VENDORS = {
  analytics: {
    src: 'https://cdn.vendor.example/sdk/3.4.1/client.js',
    integrity: 'sha384-…',
    category: 'analytics',
    origin: 'https://cdn.vendor.example',
    teardown: () => window.acme?.optOut?.(),   // called on revocation
  },
};
// one subscription, dispatching to every vendor
analyticsAllowed.subscribe((ok) => {
  for (const v of Object.values(VENDORS)) {
    if (v.category !== 'analytics') continue;
    if (ok) loadScript(v.src, { integrity: v.integrity }).catch(reportFailure);
    else v.teardown?.();      // revocation is not just "stop loading"
  }
});

The else branch is the part most implementations omit. A consent store that stops permitting a load does nothing about a script that has already loaded, and in a single-page application the visitor may spend the next twenty minutes on a page where a vendor they revoked is still running. Reactivity governs what you render; it does not govern what a third party is doing in a closure it created ten minutes ago.

Verification Checklist


Preloading Without Leaking

One optimisation is safe to keep in the document even for gated vendors, and it is worth taking: a preconnect hint to the vendor origin. It transfers no payload and reveals only that you may contact that origin — which is already public information, since the tag is in your codebase and your privacy notice names the vendor.

Placed in app.html, the hint completes DNS, TCP and TLS during the window in which the visitor is reading the consent banner, so the injected script finds a warm connection and the consent-to-execution latency drops by most of a round trip. That is the same trade the facade pattern makes on pointer approach, applied to a different waiting period.

What must not go in the document is a preload of the vendor script itself. That does transfer the payload, before any decision, which is precisely the leak the rest of this page prevents — and it will additionally be discarded unused when the visitor declines, so it is both a compliance failure and wasted bandwidth.

Interaction Matrix

Pattern Interaction
Consent gating The store is the adapter boundary; the platform behind it is swappable without touching components.
Priority hints Injected scripts start at Low. Setting fetchPriority before appendChild is the only place it applies.
Subresource integrity Same rule: integrity must be set on the element before it is appended, which the loader does.
Interaction-deferred embeds Composes directly — a Svelte component rendering a facade, with loadScript as the swap.
Worker offloading No framework integration exists, so Partytown is a manual setup here, configured in app.html.

Testing the Gate, Not Just the Loader

The loader is easy to unit-test and the gate is not, which is why the gate is usually the part that breaks. A unit test can confirm that loadScript injects one element and resolves; it cannot confirm that nothing was injected during server rendering, because there is no server rendering in a unit test.

The test that matters is therefore an integration one: render a route through the framework’s own server pipeline, take the HTML string, and assert that no vendor hostname appears in it. That assertion is three lines, runs in milliseconds, and catches every regression in this class — including the ones introduced by someone moving a tag into svelte:head for unrelated reasons.

Pair it with a second assertion over the client bundle: that the vendor URL appears only in the vendors module and not in any component. Together they pin both halves of the property — the tag is not in the HTML, and there is exactly one place it can come from.

Troubleshooting

The tag appears in view-source despite onMount. Something else is rendering it — usually a <svelte:head> block, which does participate in server rendering. Move the tag out of svelte:head entirely; a gated script does not belong there.

Two copies after navigating back and forth. The loaded map is defined inside the component rather than at module scope, so each instance gets a fresh one. It must be module-level on the client.

Consent updates never reach the component. The store was read once with get() instead of subscribed to. get() samples; a gate needs the subscription.

Different visitors see each other’s state. Per-visitor data is in module scope on the server. Move it into the client or into a per-request context.

The vendor initialises but records nothing after a route change. The script is still loaded but the vendor expects a per-view call. Hook it to afterNavigate rather than relying on re-injection.


Frequently Asked Questions

Should the tag go in app.html instead?

Only for scripts that must run before anything else and require no consent — a consent platform’s own stub, or an anti-flicker snippet. app.html is the template for every server response, so anything placed there is in the initial HTML for every visitor, which is precisely what a gated tag must not be.

The distinction is the same one that applies in every framework: the strictly necessary bootstrap goes in the document, and everything else is injected after a decision. If you find yourself wanting a gated tag in app.html for performance reasons, the answer is usually a preconnect there instead, which costs no payload and leaks no data.

How does this interact with ssr = false on a route?

Disabling server rendering removes the leak hazard for that route, because there is no server-rendered HTML to leak into — but it does not make the pattern unnecessary. The consent gate is still required, the single-injection guard is still required, and the cost of the page is now worse for everyone since nothing is rendered until JavaScript runs.

Treat ssr = false as a decision about the route’s own rendering strategy, made for its own reasons, rather than as a way to simplify third-party loading. The loader described here works identically on either setting, which is a point in its favour.

Can the consent decision be read on the server from a cookie?

Yes, and it is worth doing for returning visitors: reading a small consent cookie in a server load function lets the initial HTML reflect the stored decision, so there is no flash of a banner someone already dismissed and no layout shift when the gated content appears.

Keep the cookie minimal and non-identifying — a category bitmask and a policy version, not a copy of the consent record — and treat it as a hint rather than as the source of truth. The client-side platform remains authoritative, because it is the only place that can observe a change made in another tab.

Is there a Partytown integration for SvelteKit?

Not a first-party one, but the manual setup works: serve the Partytown library from your own origin, add the configuration snippet to app.html, and mark the target scripts with type="text/partytown". The loader above needs one change — setting the type attribute — and the rest is unchanged.

The caveats from worker offloading apply in full and are not framework-specific: only scripts that touch the DOM rarely benefit, cross-origin vendors need a same-origin reverse proxy, and the vendor’s own event volume is the measurement that tells you whether the proxy broke something.


Run both assertions in continuous integration rather than as a manual check. A consent leak is not the kind of defect anyone discovers by using the site, so the only reliable way to know it has not returned is to assert its absence on every commit.

Up: Framework Integration Patterns for Third-Party Scripts