Astro processes <script> tags through Vite by default. That is normally useful, but for external embed scripts it causes a specific problem: Astro's build pipeline strips data-* attributes from externally-hosted scripts. Without data-project, the OperatorStack embed initializes with no project key and silently records nothing. Every founder who drops in the script tag without is:inline hits this and spends an hour debugging.
Add the OperatorStack script to your layout with is:inline to prevent Astro from processing it. The widget div (data-os-widget="waitlist") works in any .astro component without an island. For SDK calls, use a <script> block in the same .astro file or a React/Vue island with client:load. Environment variables need the PUBLIC_ prefix to reach browser bundles.
Step 1: Add the Script Tag to Your Layout
Most Astro projects have a shared layout component at src/layouts/Layout.astro. That is where the script tag goes. The critical detail is is:inline.
Open src/layouts/Layout.astro and add the script tag before </body>. Replace pk_your_project_key with your key from the OperatorStack dashboard.
---
// src/layouts/Layout.astro
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
</head>
<body>
<slot />
<script is:inline src="https://operatorstack.dev/os.js" data-project="pk_your_project_key"></script>
</body>
</html>
The is:inline directive tells Astro to emit this script tag exactly as written, bypassing Vite processing. Without it, Astro may deduplicate, rebundle, or strip the data-project attribute entirely. The embed reads document.currentScript.dataset.project on init -- if that attribute is missing, the entire embed fails silently.
Do not add defer or async to the OperatorStack script tag. The embed uses document.currentScript during initialization, which is only available when the script executes synchronously. Both defer and async break this.
Step 2: Use the Default Widget
The HTML widget approach works in any .astro file without any island setup. Drop the div where you want the form to appear.
Add the widget div to any page or component.
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
---
<Layout title="Join the waitlist">
<main>
<h1>Get early access</h1>
<p>Join 400 founders waiting for launch.</p>
<div data-os-widget="waitlist"></div>
</main>
</Layout>
OperatorStack scans the DOM once the script loads and renders the signup form inside a Shadow DOM. No useEffect, no island, no extra JavaScript from your side.
Step 3: Custom Form with a Script Block
If you want custom form markup but do not need a UI framework, use an Astro <script> block. Unlike is:inline, a bare <script> block without 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.
Add a form and wire it up with a script block in the same .astro file.
---
// src/components/WaitlistForm.astro
---
<form id="waitlist-form">
<input type="email" id="waitlist-email" placeholder="your@email.com" required />
<button type="submit">Join waitlist</button>
</form>
<div id="share-links" style="display:none">
<p>You are on the list. Share to move up:</p>
<a id="share-twitter" href="#">Share on Twitter</a>
</div>
<script>
const form = document.getElementById("waitlist-form") as HTMLFormElement;
const shareSection = document.getElementById("share-links") as HTMLDivElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = (document.getElementById("waitlist-email") as HTMLInputElement).value;
const result = await window.OperatorStack.joinWaitlist({ email });
window.OperatorStack.trackEvent("waitlist_signup", {
source: document.referrer || "direct",
});
const links = window.OperatorStack.getShareLinks(result.referral_code, {
text: "I just joined -- come check it out:",
});
(document.getElementById("share-twitter") as HTMLAnchorElement).href = links.twitter;
form.style.display = "none";
shareSection.style.display = "block";
});
</script>
This pattern works for any Astro site, including fully static (SSG) projects. The <script> block runs in the browser after the layout script loads, so window.OperatorStack is available.
Type window.OperatorStack as any in a bare <script> block if you want to skip the TypeScript declaration. For full type coverage, add a global.d.ts at src/global.d.ts and re-use it across islands and script blocks.
Step 4: React Island for Custom Forms
If your project already uses @astrojs/react (or Vue/Svelte), you can build the waitlist form as a framework component. Use client:load so the form is interactive as soon as the page hydrates.
Create a React island component and import it into the page with client:load.
// src/components/WaitlistSignup.tsx
import { useState } from "react";
declare global {
interface Window {
OperatorStack: {
joinWaitlist: (p: { email: string }) => Promise<{ referral_code: string }>;
getShareLinks: (code: string, opts?: { text?: string }) => { twitter: string; copy: string };
trackEvent: (name: string, data?: Record<string, unknown>) => void;
};
}
}
export function WaitlistSignup() {
const [email, setEmail] = useState("");
const [shareLinks, setShareLinks] = useState<{ twitter: string; copy: string } | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const result = await window.OperatorStack.joinWaitlist({ email });
window.OperatorStack.trackEvent("waitlist_signup", { source: document.referrer || "direct" });
const links = window.OperatorStack.getShareLinks(result.referral_code, {
text: "Joining early -- get in line:",
});
setShareLinks(links);
setLoading(false);
}
if (shareLinks) {
return (
<div>
<p>You are on the list. Share to move up.</p>
<a href={shareLinks.twitter}>Tweet your link</a>
<button onClick={() => navigator.clipboard.writeText(shareLinks.copy)}>Copy link</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
/>
<button type="submit" disabled={loading}>
{loading ? "Joining..." : "Join waitlist"}
</button>
</form>
);
}
Then use it in an Astro page:
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import { WaitlistSignup } from '../components/WaitlistSignup';
---
<Layout title="Join the waitlist">
<main>
<h1>Get early access</h1>
<WaitlistSignup client:load />
</main>
</Layout>
The client:load directive hydrates the island immediately on page load. 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 client:load here.
Step 5: Environment Variables
For separate staging and production keys, or to avoid committing your project key to source control:
# .env
PUBLIC_OPERATORSTACK_KEY=pk_your_project_key
Then reference it in your layout:
---
// src/layouts/Layout.astro
const projectKey = import.meta.env.PUBLIC_OPERATORSTACK_KEY;
---
<script is:inline src="https://operatorstack.dev/os.js" data-project={projectKey}></script>
The PUBLIC_ prefix is required. Astro strips any variable without it from browser-accessible code. This differs from Next.js (NEXT_PUBLIC_) and Vite apps (VITE_). If data-project renders as undefined, this prefix is the first thing to check.
SSR Adapter Note
If you are using an SSR adapter (@astrojs/node, @astrojs/cloudflare, @astrojs/vercel), Astro runs your .astro component frontmatter on the server. The window object does not exist there.
The script tag and widget div are fine -- they are emitted as static HTML and initialized in the browser. The rule is: no window.OperatorStack in the frontmatter section (between --- fences). Put SDK calls only in <script> blocks or client:* islands.
---
// WRONG: frontmatter runs server-side
const result = await window.OperatorStack.joinWaitlist({ email }); // ReferenceError
---
---
// Correct: just server-side data, no window access
const title = "Join the waitlist";
---
<script>
// window.OperatorStack is accessible here -- this block runs in the browser
</script>
Common Mistakes
Forgetting is:inline on the script tag. Astro's build strips data-project without it. The embed loads but cannot find its project key, so all signups are silently dropped.
Using NEXT_PUBLIC_ or VITE_ prefix. Astro uses PUBLIC_. All three frameworks require a prefix to expose env vars to the browser, but the prefix differs across them.
Using client:idle 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 client:load for interactive forms that should respond immediately.
Calling window.OperatorStack inside --- frontmatter. This code runs on the server during SSR or at build time during SSG. window is undefined in both contexts.
Frequently Asked Questions
Why does my OperatorStack embed not work in Astro?
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.
Do I need a React or Vue island to use OperatorStack in Astro?
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.
How do I pass my project key via an environment variable in Astro?
Add PUBLICOPERATORSTACK_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 NEXTPUBLIC.
Does OperatorStack work with Astro SSR adapters (Node, Cloudflare, Vercel)?
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.
Which client directive should I use for a waitlist island?
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.