SvelteKit renders your components on the server before it ever ships them to a browser. That one fact breaks the copy-paste waitlist snippet that works fine in a Vite Svelte SPA: put window.OperatorStack.joinWaitlist() in a component body and vite build fails with ReferenceError: window is not defined during prerendering, before you ever see the form.

The fix is two lines, but you need to know which two. This guide covers the script tag placement, a signup form built with Svelte 5 runes, the SSR guard that keeps the build green, and the one kind of SvelteKit navigation that page-view tracking will not catch.

Put the OperatorStack script tag in src/app.html before </body>. Use the data-os-widget="waitlist" div for the default form, or call window.OperatorStack.joinWaitlist() from a $state-backed component guarded by browser from $app/environment. Normal SvelteKit navigation is tracked automatically because the router uses history.pushState.

Step 1: Add the Script Tag to app.html

src/app.html is the HTML shell SvelteKit wraps around every page it renders. The script belongs there, not in a Svelte component, so it loads exactly once and survives every client-side navigation after that.

1

Open src/app.html and add the script before </body>. Replace pk_your_project_key with the project key from your OperatorStack dashboard.

<!-- src/app.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%sveltekit.assets%/favicon.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
    <script src="https://operatorstack.dev/os.js" data-project="pk_your_project_key"></script>
  </body>
</html>

There is no SvelteKit plugin to register and no hooks.client.ts wiring. The browser executes this the way it would on any static page, after SvelteKit has hydrated the markup above it.

Do not put the script tag in +layout.svelte. Svelte does not execute <script src> tags inserted through a component template the way the HTML parser does on initial load, and a layout that re-renders can leave you with a tag in the DOM that never ran. app.html is parsed by the browser directly, which is what you want.

Step 2: Drop In the Default Widget

For a standard signup form, add the widget div to any route. OperatorStack scans the DOM for data-os-widget attributes after it initializes and renders the form inside a Shadow DOM, so your Tailwind or global CSS cannot collide with it.

2

Add the widget div to a page component.

<!-- src/routes/+page.svelte -->
<section>
  <h2>Join the waitlist</h2>
  <div data-os-widget="waitlist"></div>
</section>

No onMount, no bind:this, no cleanup. This markup is also safe to server-render, since it is an empty div until the client-side script fills it.

Step 3: Build a Custom Form with Runes

When the default widget does not match your design, call the SDK yourself. This is where SSR starts to matter.

3

Create a WaitlistForm.svelte component that guards every window access.

<!-- src/lib/WaitlistForm.svelte -->
<script lang="ts">
  import { browser } from "$app/environment";

  let email = $state("");
  let loading = $state(false);
  let shareLinks = $state<{ twitter: string; copy: string } | null>(null);
  let error = $state("");

  async function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    if (!browser) return;

    loading = true;
    error = "";

    try {
      await window.OperatorStack.ready;
      const result = await window.OperatorStack.joinWaitlist({ email });
      window.OperatorStack.trackEvent("waitlist_signup", { framework: "sveltekit" });

      const links = window.OperatorStack.getShareLinks(result.referral_code, {
        text: "I just joined the waitlist, check it out:",
      });
      shareLinks = { twitter: links.twitter, copy: links.copy };
    } catch {
      error = "Something went wrong. Try again.";
    } finally {
      loading = false;
    }
  }
</script>

{#if shareLinks}
  <p>You are on the list. Share to move up.</p>
  <a href={shareLinks.twitter}>Share on Twitter</a>
  <button onclick={() => navigator.clipboard.writeText(shareLinks.copy)}>
    Copy referral link
  </button>
{:else}
  <form onsubmit={handleSubmit}>
    <input type="email" bind:value={email} placeholder="your@email.com" required />
    <button type="submit" disabled={loading}>
      {loading ? "Joining..." : "Join waitlist"}
    </button>
  </form>
  {#if error}<p role="alert">{error}</p>{/if}
{/if}

The if (!browser) return line is the whole SSR fix. browser is a compile-time constant from $app/environment, so during a server render or a prerender pass the guard short-circuits before anything touches window.

window.OperatorStack.ready is a promise that resolves once the SDK has fetched your project config. Await it at the top of your handler rather than assuming the script finished loading before someone clicked submit. A visitor on a slow connection can absolutely beat the script to the button.

For TypeScript, declare the global once in src/app.d.ts instead of repeating it per component:

// src/app.d.ts
declare global {
  interface Window {
    OperatorStack: {
      ready: Promise<void>;
      joinWaitlist: (params: {
        email: string;
        name?: string;
      }) => Promise<{ success: boolean; referral_code: string; referral_link: string }>;
      getShareLinks: (
        code: string,
        opts?: { text?: string },
      ) => { twitter: string; linkedin: string; email: string; whatsapp: string; copy: string };
      trackEvent: (name: string, data?: Record<string, unknown>) => void;
      isSignedUp: () => boolean;
    };
  }
}

export {};

Route Changes Are Tracked, With One Exception

SvelteKit's client-side router calls history.pushState on every normal navigation, whether that is a link click, a goto() call, or a form action redirect. The OperatorStack embed patches history.pushState globally and listens for popstate, then schedules a page-view event 100ms later. The delay is a debounce: some routers fire both pushState and popstate for a single navigation, and the timer collapses them into one event. There is also a lastTrackedUrl check, so if the URL has not actually changed, no event is sent.

You get per-route analytics in a SvelteKit app without writing a single tracking call.

<script lang="ts">
  import { goto } from "$app/navigation";

  // Already tracked. No afterNavigate hook needed.
  function viewPricing() {
    goto("/pricing");
  }
</script>

The exception is replaceState. The embed patches history.pushState only, so goto("/thanks", { replaceState: true }) produces no page view, and neither does anything else that swaps the URL through history.replaceState. This bites most often on a post-signup redirect, where replaceState is the natural choice to keep the form out of the back-button history. If you need that navigation counted, fire it yourself: window.OperatorStack.trackEvent("page_view", { path: "/thanks" }).

Preloading is not a problem, though. The default <body data-sveltekit-preload-data="hover"> makes SvelteKit run a route's load function when a visitor hovers a link, but preloading never changes location.href, and the embed only emits a page view when the URL actually differs from the last one it tracked. Hovering ten links produces zero page views.

If you have routes you would rather not track at all, such as an admin section, exclude them at the script tag with a comma-separated prefix list:

<script
  src="https://operatorstack.dev/os.js"
  data-project="pk_your_project_key"
  data-exclude="/admin,/internal"
></script>

Returning Visitors and the Signed-Up State

joinWaitlist() persists the returned referral code to localStorage under a per-project key, so a visitor who already signed up can be recognized on their next visit without you storing anything yourself. That is useful for hiding the form on a landing page someone has already converted on.

<script lang="ts">
  import { onMount } from "svelte";

  let alreadyJoined = $state(false);

  onMount(async () => {
    await window.OperatorStack.ready;
    alreadyJoined = window.OperatorStack.isSignedUp();
  });
</script>

{#if alreadyJoined}
  <p>You are already on the list. We will email you at launch.</p>
{:else}
  <div data-os-widget="waitlist"></div>
{/if}

onMount is the other valid SSR escape hatch alongside the browser guard: SvelteKit never runs it during server rendering, so window is guaranteed to exist inside it.

Common Mistakes

Reaching for onMount when a browser guard is simpler. Both work. Use onMount when you need to read state as the component appears (the signed-up check above). Use if (!browser) return inside an event handler, since a submit handler cannot fire on the server anyway and the guard is really there to satisfy the prerenderer.

Prerendering a route that calls the SDK at module scope. If you set export const prerender = true on a route, any window reference outside a handler or onMount runs at build time. Keep SDK calls inside functions.

Assuming the widget div needs the SDK. data-os-widget="waitlist" requires zero Svelte code and zero guards. Reach for joinWaitlist() only when you want a custom UI or need the referral_code back for a share screen.

Using hash-based routing. SvelteKit uses the History API by default and there is no supported hash-router mode, so this is rarely an issue here, but if you have hand-rolled hash navigation, pushState is never called and route views will not register.

Frequently Asked Questions

Where does the OperatorStack script tag go in a SvelteKit app?

In src/app.html, right before the closing body tag. That file is the shell SvelteKit wraps around every rendered page, so the script loads once on the first request and stays loaded across client-side navigations. Do not put it in a +page.svelte or +layout.svelte file.

Why do I get ReferenceError window is not defined in SvelteKit?

SvelteKit renders your components on the server first, where there is no window object. Any code touching window.OperatorStack has to run only in the browser. Import browser from $app/environment and guard the call, or move it into onMount, which never runs during server rendering.

Do I need an npm package to use OperatorStack with Svelte?

No. OperatorStack is a script tag, not an npm package. It attaches window.OperatorStack once it loads, and you call its methods from any component the same way you would call any other browser global.

Does OperatorStack track page views across SvelteKit routes?

Yes, for normal navigation. SvelteKit's client-side router calls history.pushState on every navigation, and the OperatorStack embed patches pushState and listens for popstate, then fires a page-view event 100ms later. The one gap is goto with replaceState set to true, which calls history.replaceState instead. That method is not patched, so those navigations are not counted.

Does data-sveltekit-preload-data create phantom page views?

No. Preloading runs a route's load function early but never changes the URL, and the embed only fires a page view when location.href actually differs from the last tracked URL. Hovering over ten links does not produce ten page views.