@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:
- Human interaction: show human-only UI or route to the right in-page view.
- AI/search coverage: expose structured content when a machine-readable visitor runs JavaScript.
- Automation/QA: tag scripted runs without counting them as human demand.
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:
pending: true— the visitor exists client-side but the first scored event has not reached Signals yet. Normal for the first second or two of a session.stale: true— the snapshot is a reused or server-flagged placeholder rather than a fresh read.coverageMode: 'degraded'— your Signals Coverage allowance is exhausted for the billing period. The endpoint answers HTTP 200 with a placeholder context (all scores 0,pendingandstaletrue,behavioralClass: 'suspicious'), so an unguarded component silently personalizes on zeros until the period resets.@clickstreamhq/signalsalso emits a one-timeconsole.warnwhen it sees this.
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:
- Per-tick failures do not reach
error. The underlying subscription logs aconsole.warnand keeps polling, so a transient 5xx does not kill a long-lived listener. - There is a 15-second watchdog. If no tick has ever succeeded after 15 s, the hook sets
loading: falseand puts aSignalsNotConfiguredErroronerror("no visitor context observed after 15 s"). That is your signal that the pixel is not running or the visitor cookie is missing — not that the visitor is anonymous.
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
| Prop | Type | Default | Notes |
|---|---|---|---|
apiKey | string | required | Website key (cs_live_* / cs_test_*). |
endpoint | string | https://feynman.clickstream.com | Your 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. |
pollIntervalMs | number | 2000 | How often useVisitor polls the signals endpoint. Floor 1000. |
resolveVisitorId | () => string | undefined | SDK bridge → _cs_vid cookie → _cs_vid localStorage → legacy _cs_uid cookie | Override when your app knows the visitor id and the defaults cannot see it. |
resolveSessionId | () => string | undefined | SDK bridge → _cs_sid cookie → _cs_sid sessionStorage | Required for realtime streams; also what keeps a read to a single session partition. |
config | ClickStreamProviderConfig | — | Pass 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
- Next.js adapter — Server Component + middleware support
- Signals API — underlying
VisitorContextshape - Install — full install matrix