On the web, ClickStream anchors identity in a server-set first-party cookie on your own tracking subdomain — persistent for up to 400 days, the maximum modern browsers allow. A native app has none of that machinery: no cookie jar, no document, no Origin header. The tempting workaround is to make the app impersonate a browser — spoofed headers, pretend https:// page URLs mapped onto screens.
@clickstreamhq/react-native takes a different route: a dedicated mobile key family, a persisted visitor id, an offline-durable event queue, and — the part that makes cross-platform identity work — email and phone hashing that runs on-device and produces exactly the same hashes as the web SDK. Same hashes in, one person out.
This is a developer tutorial for the whole surface: createClickStream(), screen() / tap() / identify(), consent, session rotation, and reading live behavioral Signals from inside a screen.
First, the honest version label
The React Native SDK is a developer preview. @clickstreamhq/react-native is live on the public npm registry at 0.1.0-alpha.1 under the alpha dist-tag, MIT licensed. Everything below is the real, shipped API — but alpha means the surface can change before a stable release. (The core web package, @clickstreamhq/sdk, is stable at 1.4.0; the Signals read API it pairs with, @clickstreamhq/signals, is also an alpha preview.)
Install
# npm
npm install @clickstreamhq/react-native @clickstreamhq/signals @react-native-async-storage/async-storage
# or pnpm / yarn
pnpm add @clickstreamhq/react-native @clickstreamhq/signals @react-native-async-storage/async-storage
# Expo
npx expo install @react-native-async-storage/async-storage
@react-native-async-storage/async-storage is what persists the visitor id, the session, and the offline event queue across app restarts. It's technically optional — the SDK falls back to an in-memory store — but production apps should pass it, or every cold start looks like a brand-new visitor.
The SDK is Hermes-safe by construction: no crypto.subtle, no DOM, no window or document, no native module to link. It runs on Expo without ejecting.
Create the client once
Create the tracker in a module so the whole app shares one instance:
// lib/clickstream.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import { createClickStream } from '@clickstreamhq/react-native';
export const cs = createClickStream({
// Your dashboard's dedicated MOBILE key (Developers → Install → Mobile app).
apiKey: 'cs_mob_live_xxxxxxxxxxxx',
// Your verified first-party tracking domain (the collector). Required.
endpoint: 'https://t.yourapp.com',
// Persistence + lifecycle wiring.
storage: AsyncStorage, // survives app restarts (offline queue, visitor id)
appStateProvider: AppState, // drives 30-min idle session rotation on foreground
// Used to build device.userAgent: "Acme/2.1.0 iOS".
appName: 'Acme',
appVersion: '2.1.0',
});
Two of these fields deserve a closer look.
Mobile keys: no Origin spoofing, no fake URLs
ClickStream API keys come in scoped families — cs_live_* for browsers, cs_srv_live_* for servers, cs_mob_live_* for mobile apps, cs_test_* for testing. The collector normally enforces a browser-provenance gate: browser keys must arrive with the request provenance a real page produces. Mobile keys are exempt from that gate by design, because a native app has no Origin or Referer header to send. That means there's nothing to fake: no spoofed headers, no pretend page URL. You send real screen names, and the collector reads device.clientPlatform ('react-native' by default, or 'ios' / 'android' if you pass it) to relax the web-shaped schema and branch its bot-detection logic for native traffic.
AppState: sessions that respect how apps actually live
Apps don't get closed; they get backgrounded. Passing React Native's AppState as appStateProvider lets the SDK re-check the session window every time the app returns to the foreground. If more than 30 minutes of inactivity have passed (configurable via sessionTimeoutMs), the session rotates — so "opened the app Tuesday morning" and "opened it again Tuesday night" are two sessions for the same visitor, exactly as they would be on the web. The visitor id itself is minted once, persists across restarts, and rotates only on reset().
Track screens and taps
import { cs } from './lib/clickstream';
// Screen view. The name IS the path — no URL required.
cs.screen('CheckoutScreen', { title: 'Checkout' });
// A tap (custom event, category "tap").
cs.tap('add_to_cart', { sku: 'SKU-123', value: 49.99 });
// Any custom event.
cs.trackEvent('signup_completed', { plan: 'pro' });
screen(), tap(), trackEvent(), and identify() are fire-and-forget: they never throw into your UI and never block a render. The SDK buffers events and flushes on three triggers — when the buffer hits batchSize (default 20), on a timer (flushIntervalMs, default 10 seconds), and whenever the app returns to the foreground. On the wire, requests are always split into the collector's 25-event cap.
Wire screen() to your navigator once and every route logs automatically:
// App.tsx — React Navigation
import { NavigationContainer } from '@react-navigation/native';
import { cs } from './lib/clickstream';
export default function App() {
return (
<NavigationContainer
onStateChange={(state) => {
const route = state?.routes[state.index];
if (route) cs.screen(route.name);
}}
>
{/* ...navigators... */}
</NavigationContainer>
);
}
identify(): where cross-platform identity actually happens
Here is the part the title promised. When a user logs in or signs up in your app:
cs.identify('[email protected]', {
phone: '+1 (415) 555-1234',
userId: 'u_42',
});
Hashing runs on-device in pure JavaScript — no native crypto module, no WebCrypto polyfill. Email is hashed to SHA-256 (hem) plus MD5 (hemMd5); phone is normalized to E.164 and hashed with SHA-256. The package even exports the primitives (sha256Hex, md5Hex, normalizePhone) if you want to hash something yourself.
The critical property: these hashes are identical to what the web SDK produces for the same email or phone. When [email protected] signs in on your marketing site and later signs in inside your iOS app, both properties emit the same hashed identifiers — and the identity graph deterministically links the web visitor (anchored by its 400-day server-set cookie) and the mobile visitor (anchored by its persisted visitor id) into a single person. One journey, one set of behavioral scores, one attribution timeline — instead of a "web user" and a mysteriously similar "app user."
Privacy note: the hashes are always safe to send. Raw email and phone are forwarded only when identity-resolution consent is granted, and the collector encrypts them per-site behind a password-gated reveal flow.
Offline durability: the queue survives everything short of uninstall
Mobile networks fail constantly — subways, elevators, airplane mode. The SDK is built so you never lose the events recorded there:
| Concern | Behavior |
|---|---|
| Failed flush | Events stay queued and replay on the next flush or the next app launch |
| App killed mid-session | The queue is persisted through your storage adapter, so it survives restarts |
| Server errors / rate limits | Bounded retry with backoff on 5xx, 429, and network errors (maxRetries, default 5) |
| Permanent rejection | A permanent 4xx drops the batch so a bad payload never jams the queue |
| Unbounded growth | The queue is capped at maxQueueSize (default 1,000 events, oldest dropped first) |
The failure-handling asymmetry is deliberate: transient failures are retried because the events are recoverable; permanent failures are dropped because retrying them forever would block everything behind them. Your instrumentation code never has to think about any of this.
Consent and reset
// Turn collection on/off at runtime. analytics:false halts ALL transport.
cs.setConsent({ analytics: true, identityResolution: true });
// On logout / "forget me": clears the visitor id, session, and queued events,
// then mints a fresh anonymous visitor id.
await cs.reset();
One sharp edge worth knowing: events recorded while analytics consent is off are dropped at call time. They are never queued or sent — even if consent is granted a moment later. There is no "buffer it and hope" mode, because that would amount to collecting before consent.
Read Signals to personalize a screen
Tracking is half the story. The other half is reading back what ClickStream's edge scoring engine computed — 26 behavioral models run synchronously per event at ingest, with a CI-enforced benchmark asserting p95 under 3 ms per event — and using it inside your app. The Signals snapshot exposes 11 score fields per visitor (nine numeric scores plus emotionalState and decisionStage), alongside bot classification and identity status, with a documented round trip of roughly 50–150 ms. Snapshot reads work on every tier, including the free one.
The mobile package re-exports the full Signals surface from one subpath and pre-wires it to the visitor id your tracker already stores — no cookies, no DOM:
// lib/clickstream.ts (continued)
import { wireSignals } from '@clickstreamhq/react-native/signals';
import { cs } from './lib/clickstream';
// Configure Signals against the same mobile key + endpoint + stored visitor id.
wireSignals(cs);
// screens/Pricing.tsx
import { useEffect, useState } from 'react';
import {
getVisitorOrNull,
isHighIntent,
type VisitorContext,
} from '@clickstreamhq/react-native/signals';
export function Pricing() {
const [visitor, setVisitor] = useState<VisitorContext | null>(null);
useEffect(() => {
// Fails open to null if Signals is unavailable — your default UI still renders.
getVisitorOrNull().then(setVisitor);
}, []);
// isHighIntent: visitor.scores.intent >= 70 (the package default threshold)
const highIntent = visitor ? isHighIntent(visitor) : false;
return highIntent ? <UrgentOffer /> : <StandardPricing />;
}
Everything you'd import from @clickstreamhq/signals on the web — getVisitor, getVisitorOrNull, onVisitor, waitFor, and the helpers isBot, isHighIntent, isIdentified, plus applySignals — comes from @clickstreamhq/react-native/signals instead, so there's one import path. To keep a mounted screen current, subscribe:
import { onVisitor } from '@clickstreamhq/react-native/signals';
const sub = onVisitor((visitor) => {
// called on each poll with the latest snapshot
});
// later, on unmount:
sub.unsubscribe();
Two React Native-specific caveats, straight from the package docs. First, there are no React hooks in this package — useVisitor() lives in @clickstreamhq/react, which is web-oriented; on RN, Signals reads are plain functions you call from effects, as above. Second, applySignals rules work fine with plain run callbacks that drive component state, but the built-in effects helpers (effects.show/hide/addClass/…) manipulate the DOM — they're web-only and safe no-ops on Hermes, so don't reach for them in an app.
Configuration reference
createClickStream({
apiKey: 'cs_mob_live_…', // required: dashboard mobile key
endpoint: 'https://t.…', // required: your first-party collector domain
platform: 'ios', // 'ios' | 'android' | 'react-native' (default)
storage: AsyncStorage, // StorageAdapter; default in-memory
appStateProvider: AppState, // session rotation on foreground
appName: 'Acme', // → device.userAgent
appVersion: '2.1.0', // → device.userAgent
userAgent: 'Acme/2.1 iOS', // full override of device.userAgent
viewportWidth: 390, // device.viewport.width
viewportHeight: 844, // device.viewport.height
batchSize: 20, // auto-flush buffer threshold
flushIntervalMs: 10_000, // timer flush (0 disables)
sessionTimeoutMs: 1_800_000, // 30-min idle session window
maxRetries: 5, // delivery attempts per batch
maxQueueSize: 1_000, // offline-queue ceiling
consent: { analytics: true, identityResolution: true },
});
Bring your own storage
The storage adapter is any object with getItem / setItem / removeItem, sync or async. Prefer MMKV? Three lines:
import { MMKV } from 'react-native-mmkv';
const mmkv = new MMKV();
createClickStream({
apiKey: 'cs_mob_live_…',
endpoint: 'https://t.yourapp.com',
storage: {
getItem: (k) => mmkv.getString(k) ?? null,
setItem: (k, v) => mmkv.set(k, v),
removeItem: (k) => mmkv.delete(k),
},
});
The payoff
Put the pieces together and the cross-platform story is short:
- Web: a server-set first-party cookie on your own subdomain holds the visitor id for up to 400 days.
- Mobile: a persisted visitor id in your storage adapter survives restarts and offline stretches.
- The bridge:
identify()produces byte-identical email and phone hashes on both platforms, and the identity graph deterministically merges the two visitors into one person.
Cross-platform identity isn't a matching heuristic bolted on afterward. It's what falls out when both SDKs hash the same identifier the same way.
It's an alpha, and we're labeling it as one. If you're building in React Native or Expo, install the preview, wire screen() to your navigator, and see your app sessions land next to your web sessions — attached to the same people. Feedback at this stage genuinely shapes the stable release.