[{"data":1,"prerenderedAt":574},["ShallowReactive",2],{"blog-how-to\u002Fwaitlist-sveltekit":3},{"id":4,"title":5,"body":6,"category":543,"date":544,"dateModified":545,"description":546,"draft":547,"extension":548,"faq":549,"featured":547,"keywords":560,"meta":561,"navigation":562,"ogDescription":563,"ogTitle":564,"path":565,"readTime":566,"schemaType":567,"seo":568,"sitemap":569,"stem":570,"tags":571,"twitterCard":572,"__hash__":573},"blog\u002Fblog\u002Fhow-to\u002Fwaitlist-sveltekit.md","SvelteKit Waitlist: Add Signups With One Script Tag",{"type":7,"value":8,"toc":534},"minimark",[9,26,29,66,71,76,113,131,135,142,158,169,173,176,198,213,222,229,237,241,269,272,278,304,319,322,328,332,342,348,359,363,381,397,413,422,509],[10,11,12,13,17,18,21,22,25],"p",{},"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 ",[14,15,16],"code",{},"window.OperatorStack.joinWaitlist()"," in a component body and ",[14,19,20],{},"vite build"," fails with ",[14,23,24],{},"ReferenceError: window is not defined"," during prerendering, before you ever see the form.",[10,27,28],{},"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.",[30,31,32],"tldr",{},[10,33,34,35,38,39,42,43,46,47,49,50,53,54,57,58,61,62,65],{},"Put the OperatorStack script tag in ",[14,36,37],{},"src\u002Fapp.html"," before ",[14,40,41],{},"\u003C\u002Fbody>",". Use the ",[14,44,45],{},"data-os-widget=\"waitlist\""," div for the default form, or call ",[14,48,16],{}," from a ",[14,51,52],{},"$state","-backed component guarded by ",[14,55,56],{},"browser"," from ",[14,59,60],{},"$app\u002Fenvironment",". Normal SvelteKit navigation is tracked automatically because the router uses ",[14,63,64],{},"history.pushState",".",[67,68,70],"h2",{"id":69},"step-1-add-the-script-tag-to-apphtml","Step 1: Add the Script Tag to app.html",[10,72,73,75],{},[14,74,37],{}," 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.",[77,78,80,96,106],"step",{"number":79},"1",[10,81,82,91,92,95],{},[83,84,85,86,88,89,65],"strong",{},"Open ",[14,87,37],{}," and add the script before ",[14,90,41],{}," Replace ",[14,93,94],{},"pk_your_project_key"," with the project key from your OperatorStack dashboard.",[97,98,104],"pre",{"className":99,"code":101,"language":102,"meta":103},[100],"language-html","\u003C!-- src\u002Fapp.html -->\n\u003C!doctype html>\n\u003Chtml lang=\"en\">\n  \u003Chead>\n    \u003Cmeta charset=\"utf-8\" \u002F>\n    \u003Clink rel=\"icon\" href=\"%sveltekit.assets%\u002Ffavicon.png\" \u002F>\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \u002F>\n    %sveltekit.head%\n  \u003C\u002Fhead>\n  \u003Cbody data-sveltekit-preload-data=\"hover\">\n    \u003Cdiv style=\"display: contents\">%sveltekit.body%\u003C\u002Fdiv>\n    \u003Cscript src=\"https:\u002F\u002Foperatorstack.dev\u002Fos.js\" data-project=\"pk_your_project_key\">\u003C\u002Fscript>\n  \u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html","",[14,105,101],{"__ignoreMap":103},[10,107,108,109,112],{},"There is no SvelteKit plugin to register and no ",[14,110,111],{},"hooks.client.ts"," wiring. The browser executes this the way it would on any static page, after SvelteKit has hydrated the markup above it.",[114,115,116],"warning-box",{},[10,117,118,119,122,123,126,127,130],{},"Do not put the script tag in ",[14,120,121],{},"+layout.svelte",". Svelte does not execute ",[14,124,125],{},"\u003Cscript 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. ",[14,128,129],{},"app.html"," is parsed by the browser directly, which is what you want.",[67,132,134],{"id":133},"step-2-drop-in-the-default-widget","Step 2: Drop In the Default Widget",[10,136,137,138,141],{},"For a standard signup form, add the widget div to any route. OperatorStack scans the DOM for ",[14,139,140],{},"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.",[77,143,145,150],{"number":144},"2",[10,146,147],{},[83,148,149],{},"Add the widget div to a page component.",[97,151,156],{"className":152,"code":154,"language":155,"meta":103},[153],"language-svelte","\u003C!-- src\u002Froutes\u002F+page.svelte -->\n\u003Csection>\n  \u003Ch2>Join the waitlist\u003C\u002Fh2>\n  \u003Cdiv data-os-widget=\"waitlist\">\u003C\u002Fdiv>\n\u003C\u002Fsection>\n","svelte",[14,157,154],{"__ignoreMap":103},[10,159,160,161,164,165,168],{},"No ",[14,162,163],{},"onMount",", no ",[14,166,167],{},"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.",[67,170,172],{"id":171},"step-3-build-a-custom-form-with-runes","Step 3: Build a Custom Form with Runes",[10,174,175],{},"When the default widget does not match your design, call the SDK yourself. This is where SSR starts to matter.",[77,177,179,192],{"number":178},"3",[10,180,181],{},[83,182,183,184,187,188,191],{},"Create a ",[14,185,186],{},"WaitlistForm.svelte"," component that guards every ",[14,189,190],{},"window"," access.",[97,193,196],{"className":194,"code":195,"language":155,"meta":103},[153],"\u003C!-- src\u002Flib\u002FWaitlistForm.svelte -->\n\u003Cscript lang=\"ts\">\n  import { browser } from \"$app\u002Fenvironment\";\n\n  let email = $state(\"\");\n  let loading = $state(false);\n  let shareLinks = $state\u003C{ twitter: string; copy: string } | null>(null);\n  let error = $state(\"\");\n\n  async function handleSubmit(event: SubmitEvent) {\n    event.preventDefault();\n    if (!browser) return;\n\n    loading = true;\n    error = \"\";\n\n    try {\n      await window.OperatorStack.ready;\n      const result = await window.OperatorStack.joinWaitlist({ email });\n      window.OperatorStack.trackEvent(\"waitlist_signup\", { framework: \"sveltekit\" });\n\n      const links = window.OperatorStack.getShareLinks(result.referral_code, {\n        text: \"I just joined the waitlist, check it out:\",\n      });\n      shareLinks = { twitter: links.twitter, copy: links.copy };\n    } catch {\n      error = \"Something went wrong. Try again.\";\n    } finally {\n      loading = false;\n    }\n  }\n\u003C\u002Fscript>\n\n{#if shareLinks}\n  \u003Cp>You are on the list. Share to move up.\u003C\u002Fp>\n  \u003Ca href={shareLinks.twitter}>Share on Twitter\u003C\u002Fa>\n  \u003Cbutton onclick={() => navigator.clipboard.writeText(shareLinks.copy)}>\n    Copy referral link\n  \u003C\u002Fbutton>\n{:else}\n  \u003Cform onsubmit={handleSubmit}>\n    \u003Cinput type=\"email\" bind:value={email} placeholder=\"your@email.com\" required \u002F>\n    \u003Cbutton type=\"submit\" disabled={loading}>\n      {loading ? \"Joining...\" : \"Join waitlist\"}\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n  {#if error}\u003Cp role=\"alert\">{error}\u003C\u002Fp>{\u002Fif}\n{\u002Fif}\n",[14,197,195],{"__ignoreMap":103},[10,199,200,201,204,205,207,208,210,211,65],{},"The ",[14,202,203],{},"if (!browser) return"," line is the whole SSR fix. ",[14,206,56],{}," is a compile-time constant from ",[14,209,60],{},", so during a server render or a prerender pass the guard short-circuits before anything touches ",[14,212,190],{},[214,215,216],"tip-box",{},[10,217,218,221],{},[14,219,220],{},"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.",[10,223,224,225,228],{},"For TypeScript, declare the global once in ",[14,226,227],{},"src\u002Fapp.d.ts"," instead of repeating it per component:",[97,230,235],{"className":231,"code":233,"language":234,"meta":103},[232],"language-ts","\u002F\u002F src\u002Fapp.d.ts\ndeclare global {\n  interface Window {\n    OperatorStack: {\n      ready: Promise\u003Cvoid>;\n      joinWaitlist: (params: {\n        email: string;\n        name?: string;\n      }) => Promise\u003C{ success: boolean; referral_code: string; referral_link: string }>;\n      getShareLinks: (\n        code: string,\n        opts?: { text?: string },\n      ) => { twitter: string; linkedin: string; email: string; whatsapp: string; copy: string };\n      trackEvent: (name: string, data?: Record\u003Cstring, unknown>) => void;\n      isSignedUp: () => boolean;\n    };\n  }\n}\n\nexport {};\n","ts",[14,236,233],{"__ignoreMap":103},[67,238,240],{"id":239},"route-changes-are-tracked-with-one-exception","Route Changes Are Tracked, With One Exception",[10,242,243,244,246,247,250,251,253,254,257,258,261,262,264,265,268],{},"SvelteKit's client-side router calls ",[14,245,64],{}," on every normal navigation, whether that is a link click, a ",[14,248,249],{},"goto()"," call, or a form action redirect. The OperatorStack embed patches ",[14,252,64],{}," globally and listens for ",[14,255,256],{},"popstate",", then schedules a page-view event 100ms later. The delay is a debounce: some routers fire both ",[14,259,260],{},"pushState"," and ",[14,263,256],{}," for a single navigation, and the timer collapses them into one event. There is also a ",[14,266,267],{},"lastTrackedUrl"," check, so if the URL has not actually changed, no event is sent.",[10,270,271],{},"You get per-route analytics in a SvelteKit app without writing a single tracking call.",[97,273,276],{"className":274,"code":275,"language":155,"meta":103},[153],"\u003Cscript lang=\"ts\">\n  import { goto } from \"$app\u002Fnavigation\";\n\n  \u002F\u002F Already tracked. No afterNavigate hook needed.\n  function viewPricing() {\n    goto(\"\u002Fpricing\");\n  }\n\u003C\u002Fscript>\n",[14,277,275],{"__ignoreMap":103},[114,279,280],{},[10,281,282,283,286,287,289,290,293,294,297,298,300,301,65],{},"The exception is ",[14,284,285],{},"replaceState",". The embed patches ",[14,288,64],{}," only, so ",[14,291,292],{},"goto(\"\u002Fthanks\", { replaceState: true })"," produces no page view, and neither does anything else that swaps the URL through ",[14,295,296],{},"history.replaceState",". This bites most often on a post-signup redirect, where ",[14,299,285],{}," is the natural choice to keep the form out of the back-button history. If you need that navigation counted, fire it yourself: ",[14,302,303],{},"window.OperatorStack.trackEvent(\"page_view\", { path: \"\u002Fthanks\" })",[10,305,306,307,310,311,314,315,318],{},"Preloading is not a problem, though. The default ",[14,308,309],{},"\u003Cbody data-sveltekit-preload-data=\"hover\">"," makes SvelteKit run a route's ",[14,312,313],{},"load"," function when a visitor hovers a link, but preloading never changes ",[14,316,317],{},"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.",[10,320,321],{},"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:",[97,323,326],{"className":324,"code":325,"language":102,"meta":103},[100],"\u003Cscript\n  src=\"https:\u002F\u002Foperatorstack.dev\u002Fos.js\"\n  data-project=\"pk_your_project_key\"\n  data-exclude=\"\u002Fadmin,\u002Finternal\"\n>\u003C\u002Fscript>\n",[14,327,325],{"__ignoreMap":103},[67,329,331],{"id":330},"returning-visitors-and-the-signed-up-state","Returning Visitors and the Signed-Up State",[10,333,334,337,338,341],{},[14,335,336],{},"joinWaitlist()"," persists the returned referral code to ",[14,339,340],{},"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.",[97,343,346],{"className":344,"code":345,"language":155,"meta":103},[153],"\u003Cscript lang=\"ts\">\n  import { onMount } from \"svelte\";\n\n  let alreadyJoined = $state(false);\n\n  onMount(async () => {\n    await window.OperatorStack.ready;\n    alreadyJoined = window.OperatorStack.isSignedUp();\n  });\n\u003C\u002Fscript>\n\n{#if alreadyJoined}\n  \u003Cp>You are already on the list. We will email you at launch.\u003C\u002Fp>\n{:else}\n  \u003Cdiv data-os-widget=\"waitlist\">\u003C\u002Fdiv>\n{\u002Fif}\n",[14,347,345],{"__ignoreMap":103},[10,349,350,352,353,355,356,358],{},[14,351,163],{}," is the other valid SSR escape hatch alongside the ",[14,354,56],{}," guard: SvelteKit never runs it during server rendering, so ",[14,357,190],{}," is guaranteed to exist inside it.",[67,360,362],{"id":361},"common-mistakes","Common Mistakes",[10,364,365,374,375,377,378,380],{},[83,366,367,368,370,371,373],{},"Reaching for ",[14,369,163],{}," when a ",[14,372,56],{}," guard is simpler."," Both work. Use ",[14,376,163],{}," when you need to read state as the component appears (the signed-up check above). Use ",[14,379,203],{}," inside an event handler, since a submit handler cannot fire on the server anyway and the guard is really there to satisfy the prerenderer.",[10,382,383,386,387,390,391,393,394,396],{},[83,384,385],{},"Prerendering a route that calls the SDK at module scope."," If you set ",[14,388,389],{},"export const prerender = true"," on a route, any ",[14,392,190],{}," reference outside a handler or ",[14,395,163],{}," runs at build time. Keep SDK calls inside functions.",[10,398,399,402,403,405,406,408,409,412],{},[83,400,401],{},"Assuming the widget div needs the SDK."," ",[14,404,45],{}," requires zero Svelte code and zero guards. Reach for ",[14,407,336],{}," only when you want a custom UI or need the ",[14,410,411],{},"referral_code"," back for a share screen.",[10,414,415,418,419,421],{},[83,416,417],{},"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, ",[14,420,260],{}," is never called and route views will not register.",[423,424,425,442,463,472,497],"faq-section",{},[426,427,429],"faq-item",{"question":428},"Where does the OperatorStack script tag go in a SvelteKit app?",[10,430,431,432,434,435,438,439,441],{},"In ",[14,433,37],{},", 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 ",[14,436,437],{},"+page.svelte"," or ",[14,440,121],{}," file.",[426,443,445],{"question":444},"Why do I get ReferenceError window is not defined in SvelteKit?",[10,446,447,448,450,451,454,455,57,457,459,460,462],{},"SvelteKit renders your components on the server first, where there is no ",[14,449,190],{}," object. Any code touching ",[14,452,453],{},"window.OperatorStack"," has to run only in the browser. Import ",[14,456,56],{},[14,458,60],{}," and guard the call, or move it into ",[14,461,163],{},", which never runs during server rendering.",[426,464,466],{"question":465},"Do I need an npm package to use OperatorStack with Svelte?",[10,467,468,469,471],{},"No. OperatorStack is a script tag, not an npm package. It attaches ",[14,470,453],{}," once it loads, and you call its methods from any component the same way you would call any other browser global.",[426,473,475],{"question":474},"Does OperatorStack track page views across SvelteKit routes?",[10,476,477,478,480,481,483,484,486,487,490,491,493,494,496],{},"Yes, for normal navigation. SvelteKit's client-side router calls ",[14,479,64],{}," on every navigation, and the OperatorStack embed patches ",[14,482,260],{}," and listens for ",[14,485,256],{},", then fires a page-view event 100ms later. The one gap is ",[14,488,489],{},"goto"," with ",[14,492,285],{}," set to true, which calls ",[14,495,296],{}," instead. That method is not patched, so those navigations are not counted.",[426,498,500],{"question":499},"Does data-sveltekit-preload-data create phantom page views?",[10,501,502,503,505,506,508],{},"No. Preloading runs a route's ",[14,504,313],{}," function early but never changes the URL, and the embed only fires a page view when ",[14,507,317],{}," actually differs from the last tracked URL. Hovering over ten links does not produce ten page views.",[510,511,512,513],"content-related-articles",{},"\n  ",[514,515,512,519],"contentrelatedcard",{"href":516,"title":517,"description":518},"\u002Fblog\u002Fhow-to\u002Fwaitlist-nuxt","Nuxt Waitlist: How to Add Signups to a Nuxt App","The Vue meta-framework with the same server render guard and the identical replaceState tracking gap.",[514,520,512,524],{"href":521,"title":522,"description":523},"\u002Fblog\u002Fhow-to\u002Fastro-integration","OperatorStack Astro Integration: Waitlist, Analytics, and Forms","Astro is the other SSR-first framework in this series, with its own is:inline script requirement.",[514,525,529],{"href":526,"title":527,"description":528},"\u002Fblog\u002Fhow-to\u002Foperatorstack-webflow","Webflow Waitlist: Add Signups With Custom Code, No Zapier","The no-code builder route: paste the script into Custom Code and drop in a Div block.",[530,531],"cta-box",{"href":532,"label":533},"\u002F","Get Started Free",{"title":103,"searchDepth":535,"depth":535,"links":536},2,[537,538,539,540,541,542],{"id":69,"depth":535,"text":70},{"id":133,"depth":535,"text":134},{"id":171,"depth":535,"text":172},{"id":239,"depth":535,"text":240},{"id":330,"depth":535,"text":331},{"id":361,"depth":535,"text":362},"how-to","2026-08-03","2026-10-06","Add a waitlist to a SvelteKit app with one script tag in app.html, a runes form that clears the SSR window error, and route tracking that works.",false,"md",[550,552,554,556,558],{"question":428,"answer":551},"In src\u002Fapp.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.",{"question":444,"answer":553},"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\u002Fenvironment and guard the call, or move it into onMount, which never runs during server rendering.",{"question":465,"answer":555},"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.",{"question":474,"answer":557},"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.",{"question":499,"answer":559},"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.","sveltekit waitlist,add waitlist to sveltekit,operatorstack sveltekit,svelte waitlist form,sveltekit window is not defined,sveltekit analytics without google analytics",{},true,"One script tag in app.html plus a runes form gets you a waitlist, referral links, and route tracking in SvelteKit. Mind the browser guard.","SvelteKit Waitlist With One Script Tag","\u002Fblog\u002Fhow-to\u002Fwaitlist-sveltekit","7 min","HowTo",{"title":5,"description":546},{"loc":565},"blog\u002Fhow-to\u002Fwaitlist-sveltekit",[],"summary_large_image","5aQjoosmHxbRqYd1co-EnpaN9r15YyCvc_bYY7xxjMM",1793197856076]