@clickstreamhq/signalsis a developer preview (0.1.0-alpha). Every API on this page is the real, current surface —configure(),onVisitor(), the helper predicates — but expect it to evolve before a stable release. Of the ClickStream packages, only@clickstreamhq/sdk(1.4.0) is stable today. And to say it plainly: there is no@clickstreamhq/vue. The script tag plus a composable is the documented Vue pattern, and this post builds that composable properly.
The Missing Adapter Is the Documented Pattern
React apps get official hooks. Next.js apps get middleware and a server-side read. Vue apps get one sentence in the install docs: "A dedicated @clickstreamhq/vue adapter is not currently shipped." We'd rather state that than publish a wrapper package whose entire job is calling two functions you can call yourself.
Because that's genuinely all a Vue adapter would do. @clickstreamhq/signals is framework-agnostic browser code: it reads the current visitor's context — bot classification, identity status, and behavioral scores like intent and frustration — from the Signals API and hands it to whatever called it. Vue's Composition API is exactly the right amount of glue on top: a ref, two lifecycle hooks, one subscription. The adapter you'd otherwise install from npm is about thirty lines you can own, read, and modify. Here are those thirty lines, done honestly.
Two prerequisites before the Vue part starts: a ClickStream site with the pixel installed, and the signals package. The pixel is a 344-byte loader that pulls the ~56.5 KB gzipped bundle from your own subdomain, and its server-set first-party _cs_vid cookie — which persists up to 400 days — is the key the Signals endpoint is indexed by.
Step 0: The Pixel Stays a Script Tag
Vue apps use the same pixel install as everything else. The script tag goes in your HTML shell — index.html at the project root for Vite, public/index.html for Vue CLI:
<!-- index.html (Vite) or public/index.html (Vue CLI) -->
<script
src="https://t.example.com/sdk.js"
data-key="cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
async
></script>
Prefer everything through the bundler? Install both packages and call installClickstreamPixel instead:
npm install @clickstreamhq/sdk @clickstreamhq/signals
import { installClickstreamPixel } from '@clickstreamhq/sdk';
installClickstreamPixel({
apiKey: 'cs_live_xxx',
endpoint: 'https://t.example.com',
replay: true,
});
Either way, the SDK installs the window.clickstream bridge, and that bridge matters more than it looks: the signals client resolves the visitor ID through window.clickstream.getVisitorId() first, before falling back to the _cs_vid cookie. On CNAME first-party tracking domains the cookie is HttpOnly, so the bridge is the only path that works — load the SDK before reading Signals.
Step 1: configure() Once, in a Vue Plugin
The one hard sequencing rule in the signals package: configure({ apiKey }) must run before any read. getVisitor() and onVisitor() throw SignalsNotConfiguredError otherwise. A Vue plugin is the natural place for exactly-once app setup:
// src/plugins/clickstream.ts
import type { App } from 'vue';
import { configure } from '@clickstreamhq/signals';
export const clickstream = {
install(_app: App) {
// Must run before any getVisitor()/onVisitor() call.
configure({
apiKey: 'cs_live_your_browser_key',
endpoint: 'https://t.example.com', // your first-party tracking domain
});
},
};
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { clickstream } from './plugins/clickstream';
createApp(App).use(clickstream).mount('#app');
endpoint is technically optional — the client falls back to the shared ClickStream collector — but in production set it to your verified first-party tracking domain so Signals reads stay same-origin with the SDK's cookies. If you're new to the package itself, the Signals getting-started guide walks the full read API before any framework enters the picture.
Step 2: The useVisitor Composable
Here is the entire adapter. It subscribes with onVisitor() — which fires immediately with the first snapshot, then polls at the configured interval (default 2,000 ms, floor 1,000) — and mirrors each snapshot into a reactive ref:
// src/composables/useVisitor.ts
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
isBot,
isConfigured,
isFrustrated,
isHighIntent,
onVisitor,
type VisitorContext,
type VisitorSubscription,
} from '@clickstreamhq/signals';
export function useVisitor() {
const visitor = ref<VisitorContext | null>(null);
let subscription: VisitorSubscription | null = null;
onMounted(() => {
// Fail open: if the plugin never ran, stay null and render
// the default experience instead of throwing.
if (!isConfigured()) return;
subscription = onVisitor((ctx) => {
visitor.value = ctx;
});
});
onUnmounted(() => {
subscription?.unsubscribe(); // idempotent — safe to call twice
subscription = null;
});
const bot = computed(() => visitor.value !== null && isBot(visitor.value));
const highIntent = computed(() => visitor.value !== null && isHighIntent(visitor.value));
const frustrated = computed(() => visitor.value !== null && isFrustrated(visitor.value));
return { visitor, bot, highIntent, frustrated };
}
Every line earns its place:
- The ref starts
null, andnullis a first-class state. It means "no answer yet — render the default." Not a loading spinner, not a thrown error. onMountedkeeps the subscription client-side. Vue never runs it during server rendering, which is the entire SSR story — more below.- The
isConfigured()guard fails open. A page that forgot the plugin renders normally instead of crashing in a lifecycle hook. onUnmountedprevents leaked timers.unsubscribe()cancels the poll loop and is documented as idempotent. One caveat that trips people up: lifecycle hooks only register when the composable runs during component setup, so calluseVisitor()at the top level of<script setup>, never inside an event handler.- The computed flags encode the documented thresholds.
isHighIntent()checksvisitor.scores.intentagainstDEFAULT_HIGH_INTENT_THRESHOLD(70) andisFrustrated()checksvisitor.scores.frustrationagainst 60;isBot()readsvisitor.bot.isBot. The raw fields stay available onvisitor.valuewhen you need your own thresholds.
One timing note worth knowing: with an async script tag, the composable's first poll tick can run before sdk.js finishes loading. That's fine by design — a failed tick logs a console.warn and does not stop the subscription, so the next tick picks the visitor up once the bridge is present.
Using It in a Template
<!-- src/components/PricingHero.vue -->
<script setup lang="ts">
import { useVisitor } from '@/composables/useVisitor';
const { bot, highIntent, frustrated } = useVisitor();
</script>
<template>
<!-- Fail open: a null visitor, a bot, or an unreachable API
all land on the default branch. -->
<HighIntentOffer v-if="highIntent && !bot" />
<SupportPromo v-else-if="frustrated && !bot" />
<DefaultPricing v-else />
</template>
The shape to copy here is the v-else: the default experience is the fall-through, so every failure mode in the next section degrades to it automatically. For gates that trigger irreversible actions — firing a conversion event, opening a chat — go stricter than !bot and require visitor.value.behavioralClass === 'human' too, because automation-controlled browsers can briefly sit in suspicious or likely_bot before being classified as a named bot.
SSR and Nuxt: Client-Only by Construction
The composable never touches the server, and that's deliberate. onMounted doesn't run during server-side rendering, so on an SSR or Nuxt site the server HTML always contains the default branch. The first client render also sees visitor === null — identical output, no hydration mismatch. Personalization arrives after the first onVisitor tick and Vue re-renders the branch reactively.
Two practical notes for Nuxt:
- Register the configuration as a client-only plugin —
plugins/clickstream.client.ts— soconfigure()and the cookie-reading resolvers stay out of the server render entirely. - Accept what this pattern is: post-paint personalization. The visitor sees the default briefly, then the personalized branch. If you need the decision made before HTML is sent, that requires a server-side Signals read — today the Next.js adapter does that with a server-scoped key, and nothing Vue-shaped ships for it. We'd rather tell you that than imply a Nuxt module exists.
Every Failure Renders the Default
Personalization code earns trust by what it does when the network doesn't cooperate. The composable inherits the signals package's fail-open behavior end to end:
| Failure mode | What the client does | What your page shows |
|---|---|---|
| Endpoint unreachable / blocked | Tick fails, logs console.warn, subscription keeps retrying |
Default branch (ref stays null or keeps last value) |
| Rate limit (429) | Reuses the last good snapshot (marked stale: true) up to staleTtlMs; retries with Retry-After-aware backoff |
Last known branch, then default |
| Signals Coverage exhausted | Not an error: server returns a 200 placeholder with scores zeroed, coverageMode: 'degraded', behavioralClass: 'suspicious' |
Default branch — score and human-only gates fail closed |
| Plugin never installed | isConfigured() returns false; subscription never starts |
Default branch |
If you gate custom experiences on raw scores, also check the freshness metadata so a degraded placeholder can't masquerade as a real low-intent visitor:
const personalizable = computed(() =>
visitor.value !== null &&
!visitor.value.stale &&
visitor.value.coverageMode !== 'degraded' &&
!visitor.value.bot.isBot,
);
The deeper principle — billing and rate limits must never block page rendering — is covered in how ClickStream fails open when billing says no, and the full defensive playbook lives in fail-open personalization patterns.
Where the Composable Ends
Three things this composable deliberately doesn't do, and where to go when you need them:
- Realtime pushes.
onVisitor()polls because polling is cheaper and always fail-open. The package also shipssubscribeVisitor()for visitor-scoped WebSocket streams, but those are Scale-plan and above, require a session ID, and fall back to polling when capped — swap it in only where the page truly needs instant updates. - Declarative DOM effects. If your personalization is "add a class when intent crosses 70," you may not need a composable at all — the effects engine does rule-based DOM changes in one call.
- Server-side consumption. For dashboards or ops tooling that should observe every labeled event rather than one visitor, that's the read-only Signals Feed, not this API.
Frequently Asked Questions
Is there a dedicated @clickstreamhq/vue adapter?
No. The documented Vue pattern is the script-tag pixel install plus a composable over the framework-agnostic @clickstreamhq/signals package — which is exactly what useVisitor above is. The only npm scope that exists is @clickstreamhq; anything else claiming to be a ClickStream Vue package isn't ours.
Does useVisitor work with Nuxt and SSR?
Yes, because it's client-only by construction: onMounted never runs on the server, server HTML always carries the default branch, and the first client render matches it, so there's no hydration mismatch. Register configure() in a plugins/clickstream.client.ts plugin in Nuxt.
What happens when the Signals API is slow, rate-limited, or out of budget?
The default branch renders. Failed poll ticks warn and retry without killing the subscription; 429s reuse the last good snapshot up to staleTtlMs; coverage exhaustion returns a 200 placeholder whose zeroed scores make every gate fail closed.
The Bottom Line
Vue visitor personalization with ClickStream comes down to four small decisions:
- Pixel install stays framework-neutral: the script tag (or
installClickstreamPixel) — no Vue-specific step exists - Configuration runs exactly once:
configure({ apiKey, endpoint })in a plugin, before any read - The adapter is yours: ref +
onVisitor+onUnmountedcleanup, thirty lines you can audit in one sitting - Every failure path is the default page: null refs, stale snapshots, and degraded placeholders all fall through
v-else
An adapter you can read in one sitting beats an adapter you have to trust.