[{"data":1,"prerenderedAt":558},["ShallowReactive",2],{"blog-how-to\u002Fastro-integration":3},{"id":4,"title":5,"body":6,"category":530,"date":531,"dateModified":532,"description":533,"draft":534,"extension":535,"faq":536,"featured":534,"keywords":544,"meta":545,"navigation":546,"ogDescription":547,"ogTitle":548,"path":549,"readTime":550,"schemaType":551,"seo":552,"sitemap":553,"stem":554,"tags":555,"twitterCard":556,"__hash__":557},"blog\u002Fblog\u002Fhow-to\u002Fastro-integration.md","OperatorStack Astro Integration: Waitlist, Analytics, and Forms on Any Astro Site",{"type":7,"value":8,"toc":520},"minimark",[9,30,61,66,76,120,144,148,154,175,179,195,222,246,250,260,299,303,306,314,317,323,343,347,368,385,391,397,401,413,427,438,453,495],[10,11,12,13,17,18,21,22,25,26,29],"p",{},"Astro processes ",[14,15,16],"code",{},"\u003Cscript>"," tags through Vite by default. That is normally useful, but for external embed scripts it causes a specific problem: Astro's build pipeline strips ",[14,19,20],{},"data-*"," attributes from externally-hosted scripts. Without ",[14,23,24],{},"data-project",", the OperatorStack embed initializes with no project key and silently records nothing. Every founder who drops in the script tag without ",[14,27,28],{},"is:inline"," hits this and spends an hour debugging.",[31,32,33],"tldr",{},[10,34,35,36,38,39,42,43,46,47,49,50,52,53,56,57,60],{},"Add the OperatorStack script to your layout with ",[14,37,28],{}," to prevent Astro from processing it. The widget div (",[14,40,41],{},"data-os-widget=\"waitlist\"",") works in any ",[14,44,45],{},".astro"," component without an island. For SDK calls, use a ",[14,48,16],{}," block in the same ",[14,51,45],{}," file or a React\u002FVue island with ",[14,54,55],{},"client:load",". Environment variables need the ",[14,58,59],{},"PUBLIC_"," prefix to reach browser bundles.",[62,63,65],"h2",{"id":64},"step-1-add-the-script-tag-to-your-layout","Step 1: Add the Script Tag to Your Layout",[10,67,68,69,72,73,75],{},"Most Astro projects have a shared layout component at ",[14,70,71],{},"src\u002Flayouts\u002FLayout.astro",". That is where the script tag goes. The critical detail is ",[14,74,28],{},".",[77,78,80,97,107],"step",{"number":79},"1",[10,81,82,92,93,96],{},[83,84,85,86,88,89,75],"strong",{},"Open ",[14,87,71],{}," and add the script tag before ",[14,90,91],{},"\u003C\u002Fbody>"," Replace ",[14,94,95],{},"pk_your_project_key"," with your key from the OperatorStack dashboard.",[98,99,105],"pre",{"className":100,"code":102,"language":103,"meta":104},[101],"language-astro","---\n\u002F\u002F src\u002Flayouts\u002FLayout.astro\ninterface Props {\n  title: string;\n}\nconst { title } = Astro.props;\n---\n\n\u003C!doctype html>\n\u003Chtml lang=\"en\">\n  \u003Chead>\n    \u003Cmeta charset=\"UTF-8\" \u002F>\n    \u003Cmeta name=\"viewport\" content=\"width=device-width\" \u002F>\n    \u003Ctitle>{title}\u003C\u002Ftitle>\n  \u003C\u002Fhead>\n  \u003Cbody>\n    \u003Cslot \u002F>\n    \u003Cscript is:inline src=\"https:\u002F\u002Foperatorstack.dev\u002Fos.js\" data-project=\"pk_your_project_key\">\u003C\u002Fscript>\n  \u003C\u002Fbody>\n\u003C\u002Fhtml>\n","astro","",[14,106,102],{"__ignoreMap":104},[10,108,109,110,112,113,115,116,119],{},"The ",[14,111,28],{}," directive tells Astro to emit this script tag exactly as written, bypassing Vite processing. Without it, Astro may deduplicate, rebundle, or strip the ",[14,114,24],{}," attribute entirely. The embed reads ",[14,117,118],{},"document.currentScript.dataset.project"," on init -- if that attribute is missing, the entire embed fails silently.",[121,122,123],"warning-box",{},[10,124,125,126,129,130,133,134,137,138,140,141,143],{},"Do not add ",[14,127,128],{},"defer"," or ",[14,131,132],{},"async"," to the OperatorStack script tag. The embed uses ",[14,135,136],{},"document.currentScript"," during initialization, which is only available when the script executes synchronously. Both ",[14,139,128],{}," and ",[14,142,132],{}," break this.",[62,145,147],{"id":146},"step-2-use-the-default-widget","Step 2: Use the Default Widget",[10,149,150,151,153],{},"The HTML widget approach works in any ",[14,152,45],{}," file without any island setup. Drop the div where you want the form to appear.",[77,155,157,162,168],{"number":156},"2",[10,158,159],{},[83,160,161],{},"Add the widget div to any page or component.",[98,163,166],{"className":164,"code":165,"language":103,"meta":104},[101],"---\n\u002F\u002F src\u002Fpages\u002Findex.astro\nimport Layout from '..\u002Flayouts\u002FLayout.astro';\n---\n\n\u003CLayout title=\"Join the waitlist\">\n  \u003Cmain>\n    \u003Ch1>Get early access\u003C\u002Fh1>\n    \u003Cp>Join 400 founders waiting for launch.\u003C\u002Fp>\n    \u003Cdiv data-os-widget=\"waitlist\">\u003C\u002Fdiv>\n  \u003C\u002Fmain>\n\u003C\u002FLayout>\n",[14,167,165],{"__ignoreMap":104},[10,169,170,171,174],{},"OperatorStack scans the DOM once the script loads and renders the signup form inside a Shadow DOM. No ",[14,172,173],{},"useEffect",", no island, no extra JavaScript from your side.",[62,176,178],{"id":177},"step-3-custom-form-with-a-script-block","Step 3: Custom Form with a Script Block",[10,180,181,182,184,185,187,188,190,191,194],{},"If you want custom form markup but do not need a UI framework, use an Astro ",[14,183,16],{}," block. Unlike ",[14,186,28],{},", a bare ",[14,189,16],{}," block without ",[14,192,193],{},"src"," is processed by Vite as a module -- meaning you get TypeScript support, tree-shaking, and no data-attribute issue, since there is no external URL being processed.",[77,196,198,206,212],{"number":197},"3",[10,199,200],{},[83,201,202,203,205],{},"Add a form and wire it up with a script block in the same ",[14,204,45],{}," file.",[98,207,210],{"className":208,"code":209,"language":103,"meta":104},[101],"---\n\u002F\u002F src\u002Fcomponents\u002FWaitlistForm.astro\n---\n\n\u003Cform id=\"waitlist-form\">\n  \u003Cinput type=\"email\" id=\"waitlist-email\" placeholder=\"your@email.com\" required \u002F>\n  \u003Cbutton type=\"submit\">Join waitlist\u003C\u002Fbutton>\n\u003C\u002Fform>\n\u003Cdiv id=\"share-links\" style=\"display:none\">\n  \u003Cp>You are on the list. Share to move up:\u003C\u002Fp>\n  \u003Ca id=\"share-twitter\" href=\"#\">Share on Twitter\u003C\u002Fa>\n\u003C\u002Fdiv>\n\n\u003Cscript>\n  const form = document.getElementById(\"waitlist-form\") as HTMLFormElement;\n  const shareSection = document.getElementById(\"share-links\") as HTMLDivElement;\n\n  form.addEventListener(\"submit\", async (e) => {\n    e.preventDefault();\n    const email = (document.getElementById(\"waitlist-email\") as HTMLInputElement).value;\n\n    const result = await window.OperatorStack.joinWaitlist({ email });\n\n    window.OperatorStack.trackEvent(\"waitlist_signup\", {\n      source: document.referrer || \"direct\",\n    });\n\n    const links = window.OperatorStack.getShareLinks(result.referral_code, {\n      text: \"I just joined -- come check it out:\",\n    });\n\n    (document.getElementById(\"share-twitter\") as HTMLAnchorElement).href = links.twitter;\n    form.style.display = \"none\";\n    shareSection.style.display = \"block\";\n  });\n\u003C\u002Fscript>\n",[14,211,209],{"__ignoreMap":104},[10,213,214,215,217,218,221],{},"This pattern works for any Astro site, including fully static (SSG) projects. The ",[14,216,16],{}," block runs in the browser after the layout script loads, so ",[14,219,220],{},"window.OperatorStack"," is available.",[223,224,225],"tip-box",{},[10,226,227,228,230,231,234,235,237,238,241,242,245],{},"Type ",[14,229,220],{}," as ",[14,232,233],{},"any"," in a bare ",[14,236,16],{}," block if you want to skip the TypeScript declaration. For full type coverage, add a ",[14,239,240],{},"global.d.ts"," at ",[14,243,244],{},"src\u002Fglobal.d.ts"," and re-use it across islands and script blocks.",[62,247,249],{"id":248},"step-4-react-island-for-custom-forms","Step 4: React Island for Custom Forms",[10,251,252,253,256,257,259],{},"If your project already uses ",[14,254,255],{},"@astrojs\u002Freact"," (or Vue\u002FSvelte), you can build the waitlist form as a framework component. Use ",[14,258,55],{}," so the form is interactive as soon as the page hydrates.",[77,261,263,270,278,281,287],{"number":262},"4",[10,264,265],{},[83,266,267,268,75],{},"Create a React island component and import it into the page with ",[14,269,55],{},[98,271,276],{"className":272,"code":274,"language":275,"meta":104},[273],"language-tsx","\u002F\u002F src\u002Fcomponents\u002FWaitlistSignup.tsx\nimport { useState } from \"react\";\n\ndeclare global {\n  interface Window {\n    OperatorStack: {\n      joinWaitlist: (p: { email: string }) => Promise\u003C{ referral_code: string }>;\n      getShareLinks: (code: string, opts?: { text?: string }) => { twitter: string; copy: string };\n      trackEvent: (name: string, data?: Record\u003Cstring, unknown>) => void;\n    };\n  }\n}\n\nexport function WaitlistSignup() {\n  const [email, setEmail] = useState(\"\");\n  const [shareLinks, setShareLinks] = useState\u003C{ twitter: string; copy: string } | null>(null);\n  const [loading, setLoading] = useState(false);\n\n  async function handleSubmit(e: React.FormEvent) {\n    e.preventDefault();\n    setLoading(true);\n    const result = await window.OperatorStack.joinWaitlist({ email });\n    window.OperatorStack.trackEvent(\"waitlist_signup\", { source: document.referrer || \"direct\" });\n    const links = window.OperatorStack.getShareLinks(result.referral_code, {\n      text: \"Joining early -- get in line:\",\n    });\n    setShareLinks(links);\n    setLoading(false);\n  }\n\n  if (shareLinks) {\n    return (\n      \u003Cdiv>\n        \u003Cp>You are on the list. Share to move up.\u003C\u002Fp>\n        \u003Ca href={shareLinks.twitter}>Tweet your link\u003C\u002Fa>\n        \u003Cbutton onClick={() => navigator.clipboard.writeText(shareLinks.copy)}>Copy link\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    );\n  }\n\n  return (\n    \u003Cform onSubmit={handleSubmit}>\n      \u003Cinput\n        type=\"email\"\n        value={email}\n        onChange={(e) => setEmail(e.target.value)}\n        placeholder=\"your@email.com\"\n        required\n      \u002F>\n      \u003Cbutton type=\"submit\" disabled={loading}>\n        {loading ? \"Joining...\" : \"Join waitlist\"}\n      \u003C\u002Fbutton>\n    \u003C\u002Fform>\n  );\n}\n","tsx",[14,277,274],{"__ignoreMap":104},[10,279,280],{},"Then use it in an Astro page:",[98,282,285],{"className":283,"code":284,"language":103,"meta":104},[101],"---\n\u002F\u002F src\u002Fpages\u002Findex.astro\nimport Layout from '..\u002Flayouts\u002FLayout.astro';\nimport { WaitlistSignup } from '..\u002Fcomponents\u002FWaitlistSignup';\n---\n\n\u003CLayout title=\"Join the waitlist\">\n  \u003Cmain>\n    \u003Ch1>Get early access\u003C\u002Fh1>\n    \u003CWaitlistSignup client:load \u002F>\n  \u003C\u002Fmain>\n\u003C\u002FLayout>\n",[14,286,284],{"__ignoreMap":104},[10,288,109,289,291,292,295,296,298],{},[14,290,55],{}," directive hydrates the island immediately on page load. ",[14,293,294],{},"client:idle"," waits for the browser's idle callback, which works for non-critical UI but can cause a visible delay on the waitlist form itself -- stick with ",[14,297,55],{}," here.",[62,300,302],{"id":301},"step-5-environment-variables","Step 5: Environment Variables",[10,304,305],{},"For separate staging and production keys, or to avoid committing your project key to source control:",[98,307,312],{"className":308,"code":310,"language":311,"meta":104},[309],"language-bash","# .env\nPUBLIC_OPERATORSTACK_KEY=pk_your_project_key\n","bash",[14,313,310],{"__ignoreMap":104},[10,315,316],{},"Then reference it in your layout:",[98,318,321],{"className":319,"code":320,"language":103,"meta":104},[101],"---\n\u002F\u002F src\u002Flayouts\u002FLayout.astro\nconst projectKey = import.meta.env.PUBLIC_OPERATORSTACK_KEY;\n---\n\n\u003Cscript is:inline src=\"https:\u002F\u002Foperatorstack.dev\u002Fos.js\" data-project={projectKey}>\u003C\u002Fscript>\n",[14,322,320],{"__ignoreMap":104},[10,324,109,325,327,328,331,332,335,336,338,339,342],{},[14,326,59],{}," prefix is required. Astro strips any variable without it from browser-accessible code. This differs from Next.js (",[14,329,330],{},"NEXT_PUBLIC_",") and Vite apps (",[14,333,334],{},"VITE_","). If ",[14,337,24],{}," renders as ",[14,340,341],{},"undefined",", this prefix is the first thing to check.",[62,344,346],{"id":345},"ssr-adapter-note","SSR Adapter Note",[10,348,349,350,353,354,353,357,360,361,363,364,367],{},"If you are using an SSR adapter (",[14,351,352],{},"@astrojs\u002Fnode",", ",[14,355,356],{},"@astrojs\u002Fcloudflare",[14,358,359],{},"@astrojs\u002Fvercel","), Astro runs your ",[14,362,45],{}," component frontmatter on the server. The ",[14,365,366],{},"window"," object does not exist there.",[10,369,370,371,373,374,377,378,380,381,384],{},"The script tag and widget div are fine -- they are emitted as static HTML and initialized in the browser. The rule is: no ",[14,372,220],{}," in the frontmatter section (between ",[14,375,376],{},"---"," fences). Put SDK calls only in ",[14,379,16],{}," blocks or ",[14,382,383],{},"client:*"," islands.",[98,386,389],{"className":387,"code":388,"language":103,"meta":104},[101],"---\n\u002F\u002F WRONG: frontmatter runs server-side\nconst result = await window.OperatorStack.joinWaitlist({ email }); \u002F\u002F ReferenceError\n---\n",[14,390,388],{"__ignoreMap":104},[98,392,395],{"className":393,"code":394,"language":103,"meta":104},[101],"---\n\u002F\u002F Correct: just server-side data, no window access\nconst title = \"Join the waitlist\";\n---\n\u003Cscript>\n  \u002F\u002F window.OperatorStack is accessible here -- this block runs in the browser\n\u003C\u002Fscript>\n",[14,396,394],{"__ignoreMap":104},[62,398,400],{"id":399},"common-mistakes","Common Mistakes",[10,402,403,409,410,412],{},[83,404,405,406,408],{},"Forgetting ",[14,407,28],{}," on the script tag."," Astro's build strips ",[14,411,24],{}," without it. The embed loads but cannot find its project key, so all signups are silently dropped.",[10,414,415,423,424,426],{},[83,416,417,418,129,420,422],{},"Using ",[14,419,330],{},[14,421,334],{}," prefix."," Astro uses ",[14,425,59],{},". All three frameworks require a prefix to expose env vars to the browser, but the prefix differs across them.",[10,428,429,434,435,437],{},[83,430,417,431,433],{},[14,432,294],{}," for the waitlist form."," The browser's idle callback fires whenever the thread is free, which can be several seconds after load on a busy page. Use ",[14,436,55],{}," for interactive forms that should respond immediately.",[10,439,440,449,450,452],{},[83,441,442,443,445,446,448],{},"Calling ",[14,444,220],{}," inside ",[14,447,376],{}," frontmatter."," This code runs on the server during SSR or at build time during SSG. ",[14,451,366],{}," is undefined in both contexts.",[454,455,456,463,469,483,489],"faq-section",{},[457,458,460],"faq-item",{"question":459},"Why does my OperatorStack embed not work in Astro?",[10,461,462],{},"The most common cause is a missing is:inline directive. Without it, Astro processes the script tag through Vite, which can strip the data-project attribute the embed needs to initialize. Add is:inline to the script tag in your layout.",[457,464,466],{"question":465},"Do I need a React or Vue island to use OperatorStack in Astro?",[10,467,468],{},"No. The widget div (data-os-widget='waitlist') works in any .astro component without an island. For a custom form with SDK calls, you can use a plain script block in the same .astro file. Islands are only needed if you want to use React, Vue, or Svelte component syntax.",[457,470,472],{"question":471},"How do I pass my project key via an environment variable in Astro?",[10,473,474,475,479,480,75],{},"Add PUBLIC",[476,477,478],"em",{},"OPERATORSTACK_KEY=pk_your_key to your .env file, then reference it as import.meta.env.PUBLIC_OPERATORSTACK_KEY in your layout. The PUBLIC"," prefix is required -- Astro strips variables without it from browser bundles. This is different from Next.js, which uses NEXT",[476,481,482],{},"PUBLIC",[457,484,486],{"question":485},"Does OperatorStack work with Astro SSR adapters (Node, Cloudflare, Vercel)?",[10,487,488],{},"Yes, but the embed script is browser-only. The is:inline script tag and widget div are emitted as static HTML and initialized in the browser regardless of which SSR adapter you use. Never reference window.OperatorStack in .astro frontmatter or any server-executed code.",[457,490,492],{"question":491},"Which client directive should I use for a waitlist island?",[10,493,494],{},"Use client:load for a waitlist form. It hydrates immediately after the page loads, which prevents a visible flash where the form is unresponsive. client:visible is better for below-the-fold components like a contact form at the bottom of a marketing page.",[496,497,498,499],"content-related-articles",{},"\n  ",[500,501,498,505],"contentrelatedcard",{"href":502,"title":503,"description":504},"\u002Fblog\u002Fhow-to\u002Fwaitlist-vue","How to Add a Waitlist to a Vue App","The same index.html script tag and Shadow DOM widget pattern, for a Vite Vue project instead of Astro.",[500,506,498,510],{"href":507,"title":508,"description":509},"\u002Fblog\u002Fhow-to\u002Fwaitlist-react","How to Add a Waitlist to a React App","Add a waitlist to a Vite or Create React App project with the index.html script tag and a custom useState form component.",[500,511,515],{"href":512,"title":513,"description":514},"\u002Fblog\u002Fhow-to\u002Fwaitlist-sveltekit","How to Add a Waitlist to a SvelteKit App","The other server-rendered framework in this series, with its own app.html placement and window guard.",[516,517],"cta-box",{"href":518,"label":519},"\u002F","Get Started Free",{"title":104,"searchDepth":521,"depth":521,"links":522},2,[523,524,525,526,527,528,529],{"id":64,"depth":521,"text":65},{"id":146,"depth":521,"text":147},{"id":177,"depth":521,"text":178},{"id":248,"depth":521,"text":249},{"id":301,"depth":521,"text":302},{"id":345,"depth":521,"text":346},{"id":399,"depth":521,"text":400},"how-to","2026-06-05","2026-07-30","Add OperatorStack to an Astro site using the correct is:inline script pattern. Covers widget placement, custom SDK forms in islands, and PUBLIC_ environment variables.",false,"md",[537,538,540,542,543],{"question":459,"answer":462},{"question":465,"answer":539},"No. The widget div (data-os-widget='waitlist') works in any .astro component without an island. For a custom form with SDK calls, you can use a plain \u003Cscript> block in the same .astro file. Islands are only needed if you want to use React, Vue, or Svelte component syntax.",{"question":471,"answer":541},"Add PUBLIC_OPERATORSTACK_KEY=pk_your_key to your .env file, then reference it as import.meta.env.PUBLIC_OPERATORSTACK_KEY in your layout. The PUBLIC_ prefix is required -- Astro strips variables without it from browser bundles. This is different from Next.js, which uses NEXT_PUBLIC_.",{"question":485,"answer":488},{"question":491,"answer":494},"operatorstack astro,astro waitlist integration,astro operatorstack,add waitlist to astro,astro embed script,astro island waitlist,astro forms no backend",{},true,"Add a waitlist, analytics, and forms to your Astro site. The is:inline pattern prevents Astro from stripping your data-project attribute. Widget, SDK, and island examples.","OperatorStack Astro Integration Guide","\u002Fblog\u002Fhow-to\u002Fastro-integration","6 min","HowTo",{"title":5,"description":533},{"loc":549},"blog\u002Fhow-to\u002Fastro-integration",[],"summary_large_image","pbBJlHnZvFPY8ZMjoB5tlZRs9c9vt0JvWuv9f8TI4BA",1793197856077]