A Vite or Create React App project has no root layout file and no server render step, so the Next.js integration pattern ("use client", SSR guards, next/script) does not apply. The script tag goes in index.html like any other browser script, and window.OperatorStack is available the moment your components mount. This guide covers the setup, a custom form component, and what happens automatically when React Router changes routes.
Add the OperatorStack script tag to index.html before </body>. Use the data-os-widget="waitlist" div for the default form, or call window.OperatorStack.joinWaitlist() from a useState-backed component for a custom UI. React Router's client-side navigation is tracked automatically since OperatorStack patches history.pushState.
Step 1: Add the Script Tag to index.html
Unlike Next.js or Astro, a Vite/CRA app serves one static index.html that never re-renders on the server. Add the script there once.
Open index.html and add the script before </body>. Replace pk_your_project_key with your project key from the OperatorStack dashboard.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Your App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://operatorstack.dev/os.js" data-project="pk_your_project_key"></script>
</body>
</html>
There is no strategy prop to configure and no hydration timing to worry about. The browser loads and executes the script the same way it would for any other page, after your React bundle has already started mounting.
Step 2: Use the Default Widget Div
For a standard signup form with no custom styling logic, drop the widget div directly in JSX. OperatorStack finds it once the script loads and renders the form inside a Shadow DOM, so it will not collide with your app's CSS.
Render the widget div in any component.
// src/components/WaitlistSection.tsx
export function WaitlistSection() {
return (
<section>
<h2>Join the waitlist</h2>
<div data-os-widget="waitlist"></div>
</section>
);
}
No useEffect, no ref, no cleanup function. The script tag from Step 1 scans the DOM for data-os-widget attributes once it initializes.
Step 3: Build a Custom Form with useState
If you want the form to match your design system, call the SDK directly from a controlled component.
Create a WaitlistForm component that calls joinWaitlist on submit.
// src/components/WaitlistForm.tsx
import { useState, type FormEvent } from "react";
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; copy: string; email: string; whatsapp: string };
trackEvent: (name: string, data?: Record<string, unknown>) => void;
};
}
}
export function WaitlistForm() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [shareLinks, setShareLinks] = useState<{ twitter: string; copy: string } | null>(null);
async function handleSubmit(e: 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: "I just joined the waitlist, check it out:",
});
setShareLinks({ twitter: links.twitter, copy: links.copy });
setLoading(false);
}
if (shareLinks) {
return (
<div>
<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>
</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>
);
}
The Window interface declaration is optional but removes the Property 'OperatorStack' does not exist on type 'Window' TypeScript error everywhere you reference it. Put it in one global.d.ts file at the project root instead of repeating it per component.
window.OperatorStack.ready is a promise that resolves once the SDK has fetched your project config. If your form renders before the script finishes loading, guard the submit handler with await window.OperatorStack.ready first, or show a disabled state until it resolves.
Route Changes Are Tracked Automatically
React Router (and most other client-side routers) call history.pushState to change the URL without a full page reload. OperatorStack's embed script patches history.pushState and listens for the popstate event, then fires a page-view event 100ms after either one, debounced to avoid double-counting when a router triggers both in the same navigation. That means a single-page React app gets per-route analytics in the dashboard without wiring up a router-specific tracking call.
// No extra code needed -- this navigation is already tracked
import { useNavigate } from "react-router-dom";
function GoToPricing() {
const navigate = useNavigate();
return <button onClick={() => navigate("/pricing")}>See pricing</button>;
}
Do not call window.OperatorStack at module scope (outside a component or hook). The script tag in index.html loads asynchronously, so window.OperatorStack may still be undefined when your module first evaluates. Always read it inside an event handler, useEffect, or after checking window.OperatorStack?.ready.
Common Mistakes
Putting the script tag inside a component instead of index.html. A script tag rendered in JSX only executes once, the first time that component mounts, and React's reconciliation can skip re-inserting it on re-renders. Keep it in index.html where it loads exactly once, guaranteed.
Forgetting the TypeScript global declaration. Without it, every window.OperatorStack reference is a type error. Add it once in global.d.ts.
Assuming you need useEffect for the default widget. The data-os-widget="waitlist" div works without any React lifecycle code. Reach for the SDK and useState only when you need a custom UI.
Frequently Asked Questions
Where does the OperatorStack script tag go in a React app?
In index.html, right before the closing tag. A plain client-side React app has no server render step, so there is no SSR guard to worry about -- window is always available by the time your components mount.
Do I need a package or npm install for OperatorStack in React?
No. OperatorStack is a script tag, not an npm package. It attaches window.OperatorStack once it loads, and you call its methods from your components like any other browser global.
Does OperatorStack track page views across React Router routes?
Yes, automatically. React Router's client-side navigation calls history.pushState under the hood. OperatorStack patches pushState and listens for popstate to catch every route change, debounced by 100ms, so single-page navigation is tracked without extra configuration.
Can I use TypeScript with the OperatorStack SDK in React?
Yes. Add a global Window interface declaration once in a .d.ts file so window.OperatorStack has types across your components, or cast to any if you only call it in one place.
How do I avoid a flash of unstyled waitlist form before the script loads?
Render a lightweight loading state or skeleton until window.OperatorStack.ready resolves. The ready promise resolves once the SDK has fetched your project config, which typically takes under a second.