Engineering

Vue Visitor Personalization: a useVisitor Composable, No Adapter

There is no @clickstreamhq/vue package — and after thirty lines of Composition API, you'll see why that's fine. A ref, two lifecycle hooks, one subscription, fail-open all the way down.

July 2026 • 8 min read • Developer preview (0.1.0-alpha)

@clickstreamhq/signals is 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:

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:

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:

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:

An adapter you can read in one sitting beats an adapter you have to trust.

Personalize Your Vue App

Install the pixel, configure Signals in a plugin, and ship a composable you can audit in one sitting. If the read fails, your page renders anyway — that's the point.

Start free