Nuxt gives you no index.html to paste a script tag into. That single missing file is where most Nuxt waitlist setups go wrong: the tag ends up in app.vue, where Vue inserts it into the DOM but the browser never executes it, and the form silently renders nothing. The other half end up calling window.OperatorStack.joinWaitlist() in a <script setup> body, and nuxt build dies with window is not defined during prerendering.
Both problems have one-line fixes. This guide covers the correct nuxt.config.ts placement, a signup form with the client guard, the hydration timing rule for <ClientOnly> widgets, and the one kind of Nuxt navigation that page-view tracking misses.
Register the OperatorStack script in nuxt.config.ts under app.head.script with tagPosition: "bodyClose". Drop a data-os-widget="waitlist" div on any page for the default form, or call window.OperatorStack.joinWaitlist() behind an import.meta.client guard for a custom one. Normal Nuxt navigation is tracked automatically because Vue Router uses history.pushState.
Step 1: Register the Script in nuxt.config.ts
Nuxt builds the document head itself. app.head is the app-wide head config, so anything you put there is emitted into every page Nuxt renders, including routes you have flipped to ssr: false through routeRules.
Add a script entry to app.head in nuxt.config.ts. Replace pk_your_project_key with the project key from your OperatorStack dashboard.
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{
src: "https://operatorstack.dev/os.js",
"data-project": "pk_your_project_key",
tagPosition: "bodyClose",
},
],
},
},
});
tagPosition: "bodyClose" puts the tag just before </body>, which is where you want it: the DOM is parsed by the time it runs, so the embed can find your widget containers on its first pass. This is the same file in Nuxt 3 and Nuxt 4. Nuxt 4 moved your source into app/, but nuxt.config.ts stays at the project root either way.
Do not put <script src="https://operatorstack.dev/os.js"> in an app.vue or layout template. The HTML spec is explicit that a script element inserted through innerHTML does not execute, and that is the path a Vue template render takes. You get a tag sitting in the DOM inspector that never ran, which is a hard bug to spot because everything looks correct on screen.
If you would rather keep the config in a component, use useHead instead of raw markup. It routes through the same head system as app.head:
<!-- app/app.vue -->
<script setup lang="ts">
useHead({
script: [
{
src: "https://operatorstack.dev/os.js",
"data-project": "pk_your_project_key",
tagPosition: "bodyClose",
},
],
});
</script>
Pick one. Registering the script in both places loads the embed twice.
Step 2: Drop In the Default Widget
For a standard signup form, add the widget div to any page. After the embed initializes it scans the DOM for data-os-widget attributes and renders the form inside a Shadow DOM, so your Tailwind or global CSS cannot collide with it and its styles cannot leak into yours.
Add the widget div to a page component.
<!-- app/pages/index.vue -->
<template>
<section>
<h2>Join the waitlist</h2>
<div data-os-widget="waitlist"></div>
</section>
</template>
No onMounted, no template ref, no cleanup. This markup is safe to server-render because it is an empty div until the client-side script fills it.
The Hydration Timing Rule
This is the part specific to Nuxt, and it is worth understanding before you wrap anything in <ClientOnly>.
The embed handles the case where your widget container does not exist yet. On startup it checks document.readyState. If the document is still loading it waits for DOMContentLoaded. Otherwise it initializes immediately, and if that first pass finds zero widget containers and zero form containers, it attaches a MutationObserver to document.body and re-initializes as soon as a container appears. That observer disconnects after 10 seconds so it does not leak.
For a server-rendered Nuxt page, none of this matters: your data-os-widget div is in the HTML the server sent, so the first pass finds it. The observer is what covers <ClientOnly> widgets and SPA-mode routes, where the div appears only after Vue hydrates.
The 10-second window is the edge to watch. A widget behind a slow useAsyncData call, a lazy component that only loads on scroll, or a modal that opens on click can miss it, and the container will sit empty with no error in the console. If your waitlist form is gated behind something slow, render the container immediately and hide it with CSS rather than mounting it late.
Step 3: Build a Custom Form with script setup
When the default widget does not match your design, call the SDK yourself. This is where server rendering starts to matter.
Create a WaitlistForm.vue component that guards every window access.
<!-- app/components/WaitlistForm.vue -->
<script setup lang="ts">
const email = ref("");
const loading = ref(false);
const error = ref("");
const shareLinks = ref<{ twitter: string; copy: string } | null>(null);
async function handleSubmit() {
if (!import.meta.client) return;
loading.value = true;
error.value = "";
try {
await window.OperatorStack.ready;
const result = await window.OperatorStack.joinWaitlist({ email: email.value });
window.OperatorStack.trackEvent("waitlist_signup", { framework: "nuxt" });
const links = window.OperatorStack.getShareLinks(result.referral_code, {
text: "I just joined the waitlist, check it out:",
});
shareLinks.value = { twitter: links.twitter, copy: links.copy };
} catch {
error.value = "Something went wrong. Try again.";
} finally {
loading.value = false;
}
}
function copyLink() {
if (shareLinks.value) navigator.clipboard.writeText(shareLinks.value.copy);
}
</script>
<template>
<div v-if="shareLinks">
<p>You are on the list. Share to move up.</p>
<a :href="shareLinks.twitter">Share on Twitter</a>
<button @click="copyLink">Copy referral link</button>
</div>
<form v-else @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="your@email.com" required />
<button type="submit" :disabled="loading">
{{ loading ? "Joining..." : "Join waitlist" }}
</button>
<p v-if="error" role="alert">{{ error }}</p>
</form>
</template>
The if (!import.meta.client) return line is the whole server-render fix. import.meta.client is a compile-time constant that Vite replaces with false in the server bundle, so the guard short-circuits and the bundler can drop the window code entirely. Use it rather than process.client, which was the Nuxt 3 spelling and is deprecated.
window.OperatorStack.ready is a promise that resolves once the embed has fetched your project config. Await it at the top of the handler instead of assuming the script finished loading before someone clicked submit. Every SDK method awaits it internally too, so a visitor on a slow connection who beats the script to the button gets a queued call rather than a crash.
You do not have to pass a referral code. If the visitor arrived on a ?ref= link, joinWaitlist() reads the stored code and attributes the signup for you. Pass ref explicitly only when you are overriding that.
For TypeScript, declare the global once in a .d.ts at the project root. Nuxt includes root-level declaration files in the generated tsconfig, so no import is needed in components:
// operatorstack.d.ts
declare global {
interface Window {
OperatorStack: {
ready: Promise<void>;
joinWaitlist: (params: {
email: string;
name?: string;
ref?: 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, metadata?: Record<string, unknown>) => void;
getReferralLink: () => { referral_code: string; referral_link: string } | null;
isSignedUp: () => boolean;
};
}
}
export {};
Route Changes Are Tracked, With One Exception
Vue Router calls history.pushState on every push navigation, whether that came from a <NuxtLink> click, a navigateTo() call, or router.push(). 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 one navigation, and the timer collapses them into a single event. A lastTrackedUrl check drops anything where the URL did not actually change.
Per-route analytics in a Nuxt app costs you zero tracking calls.
<script setup lang="ts">
// Already tracked. No router.afterEach hook needed.
function viewPricing() {
navigateTo("/pricing");
}
</script>
The exception is replace navigation. navigateTo("/thanks", { replace: true }) and router.replace("/thanks") both call history.replaceState, which the embed does not patch, so no page view fires. This bites most often on a post-signup redirect, where replacing is the natural choice to keep the form out of the back-button history. If you need that view counted, fire it yourself: window.OperatorStack.trackEvent("page_view", { path: "/thanks" }).
To keep whole sections out of your analytics, such as an authenticated dashboard, exclude them by path prefix on the script entry:
// nuxt.config.ts
script: [
{
src: "https://operatorstack.dev/os.js",
"data-project": "pk_your_project_key",
"data-exclude": "/admin,/account",
tagPosition: "bodyClose",
},
],
The list is comma-separated and matched as a prefix, so /admin covers /admin/users and everything else beneath it.
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 is recognizable on their next visit without you storing anything server-side. That is enough to stop showing a form to someone who already converted.
<script setup lang="ts">
const alreadyJoined = ref(false);
onMounted(async () => {
await window.OperatorStack.ready;
alreadyJoined.value = window.OperatorStack.isSignedUp();
});
</script>
<template>
<p v-if="alreadyJoined">You are already on the list. We will email you at launch.</p>
<div v-else data-os-widget="waitlist"></div>
</template>
onMounted is the other valid escape hatch alongside import.meta.client. Nuxt never runs it during server rendering, so window is guaranteed to exist inside it. If you want the visitor's own referral link back rather than a boolean, getReferralLink() returns { referral_code, referral_link } or null when they have not signed up.
Common Mistakes
Putting the script tag in a component template. It renders, it sits in the DOM, and it never runs. app.head in nuxt.config.ts or useHead in a component are the two paths that work.
Reaching for onMounted when import.meta.client is simpler. Both work. Use onMounted when you need to read state as the component appears, like the signed-up check above. Use if (!import.meta.client) return inside an event handler, since a submit handler cannot fire on the server anyway and the guard exists to satisfy the build.
Prerendering a route that calls the SDK at module scope. nuxt generate and any route covered by prerender: true in routeRules execute your setup code at build time. Keep SDK calls inside handlers or onMounted and the prerender pass never touches them.
Assuming the widget div needs the SDK. data-os-widget="waitlist" needs zero Vue code and zero guards. Reach for joinWaitlist() only when you want custom UI or need the referral_code back to build a share screen.
Mounting the widget container behind a slow fetch. The embed's re-scan observer gives up after 10 seconds. Render the container up front and toggle its visibility instead of toggling its existence.
Frequently Asked Questions
Where does the OperatorStack script tag go in a Nuxt app?
In nuxt.config.ts, as an entry in app.head.script with tagPosition set to bodyClose. Nuxt has no index.html for you to edit, and a script src tag written into a component template does not execute the way the HTML parser runs one. The app.head config is emitted into every page Nuxt renders, so the script loads once and survives client-side navigation.
Why do I get window is not defined when I build my Nuxt app?
Nuxt renders components on the server, where there is no window object. A window.OperatorStack call in the body of a <script setup> block runs during the server render and during nuxt build prerendering. Wrap it in if (import.meta.client), or move it into onMounted, which Nuxt never runs on the server.
Do I need a Nuxt module or npm package for OperatorStack?
No. OperatorStack is a single script tag that attaches window.OperatorStack when it loads. There is no module to add to the modules array and no plugin file to write. You call its methods from any component the way you would call any other browser global.
Does OperatorStack track page views across Nuxt routes?
Yes for normal navigation. Vue Router calls history.pushState on every push navigation, and the OperatorStack embed patches pushState and listens for popstate, then fires a page view 100ms later. The gap is replace navigation: navigateTo with replace set to true and router.replace both call history.replaceState, which the embed does not patch, so those are not counted.
Does the waitlist widget work inside ClientOnly?
Yes, with one timing caveat. If the embed initializes and finds no widget containers on the page, it watches document.body for containers appearing later and disconnects that observer after 10 seconds. A <ClientOnly> widget that renders during normal hydration is well inside that window. A widget gated behind a slow fetch that resolves after 10 seconds is not, and will stay empty.