@clickstreamhq/react

Official React adapter. It configures @clickstreamhq/signals in React and gives components a stable hook surface for visitor context.

Tracking still comes from the browser pixel. Install the pixel with the script tag or @clickstreamhq/sdk so _cs_vid (the visitor id; legacy _cs_uid fallback), _cs_sid, window.clickstream.identify(), and window.clickstream.trackEvent() exist before your hooks need them.

Peer deps: react ^18 || ^19 and @clickstreamhq/signals.

Use a website key (cs_live_* / cs_test_*) here — everything in this package runs in the browser. Never pass a cs_mob_live_* or cs_srv_live_* key to the provider; those are provenance-exempt secrets.

Use the React adapter when a component needs to react to a ClickStream lane:

Install

pnpm add @clickstreamhq/react @clickstreamhq/sdk @clickstreamhq/signals

Provider setup

Wrap your app — usually in the top-level client component or the Next.js App Router root layout:

// app/providers.tsx
'use client';

import { useEffect } from 'react';
import { installClickstreamPixel } from '@clickstreamhq/sdk';
import { ClickStreamProvider } from '@clickstreamhq/react';

export function Providers({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    installClickstreamPixel({
      apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!,
      endpoint: 'https://t.example.com',
      replay: true,
    });
  }, []);

  return (
    <ClickStreamProvider
      apiKey={process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!}
      endpoint="https://t.example.com"
    >
      {children}
    </ClickStreamProvider>
  );
}

Replace t.example.com with your first-party tracking domain. The endpoint is used by both the SDK and the signals client.

If your app already has the static script tag in <head>, you can omit installClickstreamPixel() and keep the provider.

Copy-Paste: Lane-Aware Component

'use client';

import { useVisitor } from '@clickstreamhq/react';

export function PageAction() {
  const { ctx: visitor, loading } = useVisitor();

  if (loading || !visitor) return <DefaultAction />;

  if (!visitor.bot.isBot && visitor.behavioralClass === 'human') {
    if (visitor.scores.intent >= 70) return <HumanDetailView />;
    if (visitor.scores.frustration >= 60) return <HelpPanel />;
    return <DefaultAction />;
  }

  if (
    visitor.bot.category === 'ai_agent' ||
    visitor.bot.category === 'search_crawler'
  ) {
    return <MachineReadableAction />;
  }

  if (visitor.bot.category === 'automation') {
    return <QaCoverageMarker sessionId={visitor.session.sessionId} />;
  }

  return <AccessibleStaticAction />;
}

The important rule is simple: human-only UI checks for behavioralClass === 'human' and bot.isBot === false. Non-human lanes stay measurable, but they do not trigger human-only actions.

Check freshness before you personalize

A VisitorContext is not always live data, and the difference is not visible in the scores. Three fields tell you when to fall back to your default UI:

const trustworthy = ctx && !ctx.pending && !ctx.stale && ctx.coverageMode !== 'degraded';
if (!trustworthy) return <DefaultAction />;

reason tells the two placeholder cases apart: 'visitor_initializing' clears within seconds, 'signals_coverage_limit_reached' persists for the rest of the billing period. ageMs, snapshotVersion, and transport ('rest' | 'stream' | 'cache' | 'stale') are also on the context if you want to log or gate more precisely.

Hooks

useVisitor(options?): { ctx, loading, error }

Returns the current VisitorContext plus loading/error state. ctx is null until the first successful tick — while the signals endpoint warms up, when no visitor id can be resolved, or when every poll is failing.

'use client';

import { useVisitor } from '@clickstreamhq/react';

export function DocumentationPanel() {
  const { ctx: visitor, loading } = useVisitor();

  if (loading || !visitor) return <DefaultDocs />;
  if (visitor.scores.intent >= 70) return <DetailedView />;
  if (visitor.scores.frustration >= 60) return <HelpView />;
  return <DefaultView />;
}

The hook re-renders on every VisitorContext update — internally a 2-second poll of the signals endpoint. pollIntervalMs values below 1000 ms are clamped to 1000 ms.

Two behaviours worth knowing before you write error handling:

options.realtime (default false) swaps polling for the per-visitor WebSocket stream:

const { ctx } = useVisitor({ realtime: true });

Realtime is gated on the matched site's plan — Scale and above — or a key carrying the signals:stream permission. On a plan without it the stream open answers 403 plan_upgrade_required and the client falls back to polling automatically, so the hook keeps working. Two costs to know: opening a stream reserves 300 Signals Coverage units up front, and the stream URL carries the API key as a key= query parameter (it is a public website key, but it will appear in proxy logs and browser history).

The stream also requires a session id. Without one the client silently falls back to polling; provide resolveSessionId on the provider if _cs_sid is not readable from page JavaScript.

Human-only UI recipe

'use client';

import { useVisitor, useTrack } from '@clickstreamhq/react';

export function PriorityActionButton() {
  const { ctx } = useVisitor();
  const track = useTrack();

  const shouldShow =
    ctx &&
    !ctx.bot.isBot &&
    ctx.behavioralClass === 'human' &&
    ctx.scores.conversionReadiness >= 70;

  if (!shouldShow) return null;

  return (
    <button onClick={() => track({ name: 'priority_action_clicked', category: 'engagement' })}>
      Continue
    </button>
  );
}

AI/search structured-content recipe

'use client';

import { useVisitor } from '@clickstreamhq/react';

export function StructuredFaq({ faqs }) {
  const { ctx } = useVisitor();
  const discoveryLane =
    ctx?.bot.category === 'ai_agent' ||
    ctx?.bot.category === 'search_crawler';

  if (!discoveryLane) return null;

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify({
          '@context': 'https://schema.org',
          '@type': 'FAQPage',
          mainEntity: faqs.map((faq) => ({
            '@type': 'Question',
            name: faq.question,
            acceptedAnswer: { '@type': 'Answer', text: faq.answer },
          })),
        }),
      }}
    />
  );
}

For crawlers and answer agents that do not execute JavaScript, use Edge capture. This component is the enhancement path for machine-readable clients that do run page code.

Automation QA recipe

'use client';

import { useEffect } from 'react';
import { useVisitor } from '@clickstreamhq/react';

export function QaLaneMarker() {
  const { ctx } = useVisitor();

  useEffect(() => {
    if (ctx?.bot.category !== 'automation') return;
    window.dispatchEvent(new CustomEvent('clickstream:qa-coverage', {
      detail: {
        page: location.pathname,
        sessionId: ctx.session.sessionId,
        score: ctx.bot.score,
      },
    }));
  }, [ctx]);

  return null;
}

useIdentify(): (email: string, phone?: string) => Promise<void>

Returns a callback that forwards to window.clickstream.identify(email, phone?), which hashes the email (SHA-256 + MD5) and the phone (SHA-256 of the E.164 form) inside the SDK and sends an identify event. Pass a phone number as the second argument when you have one.

The returned callback rejects in three cases, so wrap it if a failure must not surface to the user: the provider is not configured yet, email is not a non-empty string, or the pixel has not booted (window.clickstream.identify is not available — the bundle loads asynchronously after the loader tag).

'use client';

import { useIdentify } from '@clickstreamhq/react';

export function LoginForm() {
  const identify = useIdentify();
  return (
    <form onSubmit={async (e) => {
      e.preventDefault();
      const email = (e.currentTarget.elements.namedItem('email') as HTMLInputElement).value;
      await identify(email);
    }}>
      <input name="email" type="email" />
      <button type="submit">Sign in</button>
    </form>
  );
}

useTrack(): (event: TrackEventInput) => void

Returns a callback that fires a custom event.

'use client';

import { useTrack } from '@clickstreamhq/react';

export function FilterToggle() {
  const track = useTrack();
  return (
    <button onClick={() => track({ name: 'docs_filter_changed', category: 'interaction' })}>
      Apply filter
    </button>
  );
}
interface TrackEventInput {
  name: string;
  category?: string;
  action?: string;
  label?: string;
  value?: number;
  metadata?: Record<string, string | number | boolean | null>;
}

name, category, action, label and value are the five fields the custom-event pipeline actually delivers — the same shape as CustomEvent in the event schema.

metadata is a convenience bag, not a storage slot. Keys named category / action / label (string values) and value (number) are promoted into the matching top-level field when it is unset; every other key is dropped and never leaves the browser, with a one-time development console.warn naming it. If you need arbitrary data stored, serialize it into label yourself.

Unlike useIdentify, useTrack does not throw when the pixel has not booted: it logs a warning and drops the event so a button click never surfaces an error to your user. It does throw when called before the provider is configured or with an empty name.

useClickStream(): ClickStreamState

Low-level meta-hook. Returns { configured, error }. Prefer useVisitor, useIdentify, and useTrack for component code.

'use client';

import { useClickStream } from '@clickstreamhq/react';

export function DebugPanel() {
  const { configured, error } = useClickStream();
  if (!configured) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <p>ClickStream is configured.</p>;
}

Throws if called outside a <ClickStreamProvider>. So do useVisitor, useIdentify, and useTrack — they all read the same context.

Provider props

PropTypeDefaultNotes
apiKeystringrequiredWebsite key (cs_live_* / cs_test_*).
endpointstringhttps://feynman.clickstream.comYour first-party tracking domain. The default works, but reads then leave your origin — set it to the tracking domain that serves your pixel so Signals reads stay same-origin with its cookies.
pollIntervalMsnumber2000How often useVisitor polls the signals endpoint. Floor 1000.
resolveVisitorId() => string | undefinedSDK bridge → _cs_vid cookie → _cs_vid localStorage → legacy _cs_uid cookieOverride when your app knows the visitor id and the defaults cannot see it.
resolveSessionId() => string | undefinedSDK bridge → _cs_sid cookie → _cs_sid sessionStorageRequired for realtime streams; also what keeps a read to a single session partition.
configClickStreamProviderConfigPass the whole config as one object instead of individual props. Individual props win when both are given.

The provider calls configure() on mount and reset() on unmount, and re-runs whenever apiKey, endpoint, pollIntervalMs, or either resolver changes. If configure() throws — an empty apiKey is the realistic case — the provider still renders its children with { configured: false, error } rather than crashing your tree; hooks below it degrade instead of failing.

Session id is not optional on Hobby

GET /v1/signals/:visitorId rejects reads without a sessionId on the free plan with 400 session_id_required, and free keys are clamped to 10 Signals reads/second (100/s on Growth). The default resolver reads window.clickstream.getSessionId() first, so this is handled whenever the full pixel is on the page. It matters when the pixel is not: on a first-party CNAME the _cs_vid / _cs_sid cookies are HttpOnly server cookies that page JavaScript cannot read, so without the bridge you must supply resolveVisitorId / resolveSessionId yourself.

On paid plans a read without a session id still works, but it fans out across every live-session partition and bills one Signals Coverage unit per partition instead of one.

Server-side rendering

The React adapter is client-only ('use client'). Import it from a Client Component. For Next.js Server Components + Route Handlers, use @clickstreamhq/next — it exposes getServerVisitor() which reads the first-party cookie server-side.

The two adapters are designed to coexist: the Next middleware pre-fetches the VisitorContext into a request header, the server helper returns a snapshot at render time, and the React provider keeps the client in sync for interaction events.

Bundle impact

@clickstreamhq/react itself is small and imports @clickstreamhq/signals. The event-tracking pixel is loaded separately from your first-party tracking domain through the script tag or @clickstreamhq/sdk installer. That split keeps the React hooks thin and keeps the browser tracker centrally updated.

Migration

If you were manually adding ClickStream in React, the migration is mechanical:

+ import { installClickstreamPixel } from '@clickstreamhq/sdk';
+ import { ClickStreamProvider } from '@clickstreamhq/react';
+ useEffect(() => {
+   installClickstreamPixel({ apiKey, endpoint: 'https://t.example.com' });
+ }, []);
+ <ClickStreamProvider apiKey={…} endpoint="https://t.example.com">…</ClickStreamProvider>

+ const track = useTrack();
+ track({ name: 'help_panel_opened' });

See also