# SplitHub > Analytics and A/B testing for websites and mobile apps built with coding agents, in one install. Analytics properties collect pageviews (or app screens) and `track()` events (reports by page, source, channel, country, device, platform, app version and UTM; funnels, paths, retention, realtime); experiments (A/B, multivariate, redirect) are declared in `splithub.config.ts` or the dashboard, rendered with `@splithub/react`, `@splithub/next` or `@splithub/react-native` (or the drop-in script; iOS and Android have native SDKs), and the verdict is read from the `splithub` CLI, the `@splithub/mcp` server or the dashboard. Events go to your own database (self-hosted, free) or to SplitHub Cloud (50,000 free exposures, pageviews and events per project per month, packs from $5, Pro $19/month). One command sets everything up in a project: `npx splithub` (interactive) or `npx splithub init --mode cloud --yes` (agents). The CLI installs agent skills (.claude/skills, .cursor/rules) and an MCP server entry, so Claude Code, Cursor and Codex know how to add analytics, create experiments and read both. `npx splithub analytics connect` adds an analytics property to the app. Apps: React Native and Expo use `@splithub/react-native` (`npx splithub init` detects them and wraps the entry); native iOS uses the Swift package `SplitHub` and Android `io.splithub:splithub` (both new, version 0.1.0). Screens are page events; targeting takes `platform` and `app_version` (`gte` / `lt`, dotted versions compared part by part); reports break down by platform and app version; events wait on the device while offline; app experiments start and change in the dashboard without a store release. Every docs page below is also available as raw Markdown by appending `.md` to its URL. The whole documentation in one file: https://splithub.io/llms-full.txt How agents get access to a cloud project (browser approval by a human, no self-registration): https://splithub.io/auth.md ## Start - [Getting started](https://splithub.io/docs/getting-started.md): Set up SplitHub in a website or an app, collect pageviews, screens and events, create an experiment and read its verdict. - [How it works](https://splithub.io/docs/concepts.md): Config file as source of truth, analytics properties, deterministic assignment, exposures and goals, self-hosted vs cloud. - [splithub.config.ts](https://splithub.io/docs/config.md): Every field of the config: storage, goals, properties, experiments, variants, traffic, schedule, targeting. ## Analytics - [Analytics](https://splithub.io/docs/analytics.md): Properties, setup in one command, automatic pageviews, track() and identify(), events and goals, one install with experiments. - [Reports](https://splithub.io/docs/analytics-reports.md): Dimensions (platform and app version too), filters, compare, segments, funnels, paths, retention cohorts, realtime, export, notes and alerts. ## Experiments - [Redirect experiments](https://splithub.io/docs/redirect-experiments.md): Test whole pages: match a path, send each variant to its own URL with middleware, a script or short links. - [Targeting flows](https://splithub.io/docs/targeting.md): Country, device, UTM, cookie, time, platform and app version conditions as a node graph, in the dashboard and in the config. - [Goals and revenue](https://splithub.io/docs/goals.md): Any event as a goal, pageview and click goals, revenue props, attribution by first exposure. - [Statistics and verdicts](https://splithub.io/docs/statistics.md): A sequential test that is safe to peek, SRM check, minimum sample, data-quality stars, guardrails, when a verdict is final. ## SDKs - [React](https://splithub.io/docs/react.md): SplitHubProvider with a property, /, useVariant, useValue, useTrack, useIdentify, automatic pageviews. - [Next.js](https://splithub.io/docs/nextjs.md): Middleware, Server Components, redirect experiments without flicker, live overrides, analytics on the provider. - [Drop-in script](https://splithub.io/docs/script.md): sh.js for sites without a build step: pageviews and events with data-property, redirect experiments, splithub.track and identify. ## Apps - [Apps](https://splithub.io/docs/apps.md): A/B testing and analytics in mobile apps: screens as page views, app version targeting, offline events, retention, and why app experiments live in the cloud. - [React Native and Expo](https://splithub.io/docs/react-native.md): @splithub/react-native: install with npx splithub init, SplitHubProvider, Expo Router and React Navigation screens, hooks, offline queue, release builds. - [iOS (Swift)](https://splithub.io/docs/ios.md): The SplitHub Swift package for iOS and macOS: SplitHub.start, variants and values, SwiftUI SplitHubExperiment, screens, events, offline and QA. - [Android (Kotlin)](https://splithub.io/docs/android.md): io.splithub:splithub for Android: SplitHub.start in Application, variants and values, Jetpack Compose Experiment and TrackScreen, offline and QA. ## Tools - [CLI reference](https://splithub.io/docs/cli.md): init (websites, React Native, Expo, iOS, Android), create, start/pause/finish, status, results, analytics, push, pull, login, doctor, skills. - [MCP server](https://splithub.io/docs/mcp.md): @splithub/mcp for Claude Code, Cursor and Codex: tools for experiments and analytics on websites and apps, setup, working rules. - [Agent skills](https://splithub.io/docs/agent-skills.md): The nine skills the CLI installs and how an agent uses them to run experiments and analytics correctly, on websites and in apps. - [HTTP API](https://splithub.io/docs/api.md): Ingest of exposures, goals, pageviews and events, experiment and property endpoints, key scopes, live overrides JSON, errors. ## Cloud and data - [Self-hosted (your database)](https://splithub.io/docs/self-hosted.md): The ingest handler, the Store interface, Postgres/MySQL/SQLite/D1, the events table, attribution SQL, raw analytics counts. - [SplitHub Cloud](https://splithub.io/docs/cloud.md): Dashboard for analytics and experiments, projects, keys, live overrides, event gate, retention, what is billed. - [Alerts, digest, AI reviews](https://splithub.io/docs/alerts.md): Rules that pause or finish tests, channels, the scheduled digest, AI reviews at start, during progress checks and on finish. - [Short links](https://splithub.io/docs/links.md): Weighted and targeted destinations, sticky choice, UTM pass-through, click stats, QR codes. - [Plans and limits](https://splithub.io/docs/pricing.md): Free, packs and Pro side by side, what counts toward the allowance (exposures, pageviews, events), what happens at the limit. ## Site - [SplitHub: analytics and A/B testing in one install](https://splithub.io/) - [Pricing](https://splithub.io/pricing) - [Documentation](https://splithub.io/docs) - [SplitHub compared to analytics and A/B testing tools](https://splithub.io/compare) - [Free marketing tools](https://splithub.io/tools) - [UTM generator](https://splithub.io/tools/utm-generator) - [QR code generator](https://splithub.io/tools/qr-generator) - [Slack emoji converter](https://splithub.io/tools/slack-emoji-converter) - [Contact](https://splithub.io/contact) - [Terms](https://splithub.io/terms) - [Privacy](https://splithub.io/privacy) ## Packages - splithub (CLI): https://www.npmjs.com/package/splithub - @splithub/react, @splithub/next, @splithub/react-native, @splithub/core, @splithub/node, @splithub/stats, @splithub/adapter-sqlite, @splithub/adapter-d1, @splithub/mcp - Native SDKs: Swift package SplitHub (iOS 14+, macOS 11+); Android io.splithub:splithub and io.splithub:splithub-compose (min SDK 21) - Source: https://github.com/andreybo/splithub-ab-testing-platform - Cloud API base URL: https://go.splithub.io (JSON, no envelope under /v1/*) - Dashboard: https://app.splithub.io --- # Getting started SplitHub is analytics and experiments in one install, for websites and apps whose code is written with a coding agent. An analytics property counts pageviews (or app screens) and the events you send with `track()`; experiments (A/B, multivariate, redirect) are declared in `splithub.config.ts` or the dashboard, rendered with React, Next.js or React Native (or decided by a drop-in script; iOS and Android have native SDKs), and read from the CLI, the MCP server, the dashboard or an email. Events go to your own database or to SplitHub Cloud. ## One command In the folder of your app: ```bash npx splithub ``` The interactive menu covers experiments, analytics, short links, alerts, notes and allowed domains. On a new app, "Set up this app" walks through: sign in through a link (no key to paste), create or pick a cloud project, write `splithub.config.ts` and the framework files, print the provider integration snippet, install the agent skills and the MCP server entry, create the first experiment, push it. Analytics → Set up connects an analytics property. Every step is also a subcommand with flags and `--json`, which is what an agent uses: ```bash npx splithub init --mode cloud --yes # or --mode self for your own database npx splithub analytics connect # pick or create a property, name it on your provider npx splithub create --kind value --id hero-title --variants control,bold \ --value "control=Old headline" --value "bold=New headline" --goal signup --yes npx splithub push npx splithub results hero-title ``` Prefer to let the agent do it? Open the project in Claude Code, Cursor or Codex and say: *"Set up SplitHub analytics and A/B test the headline on the home page."* After `init` the agent has the [skills](/docs/agent-skills) and the [MCP server](/docs/mcp) it needs. ## What `init` writes | File | Purpose | |---|---| | `splithub.config.ts` | Goals, analytics properties and experiments. The source of truth. | | Provider snippet in the command output | Add it to `app/layout.tsx` or `main.tsx` to wrap the app in the provider. | | `middleware.ts` (Next.js) | Visitor cookie, redirect experiments, header for Server Components. | | `app/api/splithub/[[...path]]/route.ts` (self mode) | Ingest and results handler with a store. | | `splithub/store.ts` (self mode, `--db postgres` or `mysql`) | A working store for your database. | | `.claude/skills/*`, `.cursor/rules/splithub.mdc`, `AGENTS.md` or `CLAUDE.md` section | Agent skills. | | `.mcp.json`, `.cursor/mcp.json` | MCP server registration (skip with `--no-mcp`). | | `app/_layout.tsx` or `App.tsx` (React Native, Expo) | Wrapped in `` with `` or ``. | ## In a mobile app `init` notices React Native and Expo projects, installs `@splithub/react-native` and wraps the app entry with screen tracking. In an Xcode or Gradle project it prints the Swift package or Gradle line and a quick start instead: native apps take their experiments from SplitHub Cloud, so there is no config file to write. Start with [Apps](/docs/apps), then [React Native and Expo](/docs/react-native), [iOS](/docs/ios) or [Android](/docs/android). ## Turn on analytics ```bash npx splithub analytics connect ``` It picks the project's property (or creates one; ids look like `sh-x1h78w`) and adds `property="sh-…"` to the `` in your code. In cloud mode the property is live at once, no push needed. From then on the provider sends a pageview on load and on every client-side navigation, and every `track(name, props?)` call is recorded as an event. Any event name works; nothing to declare first. Without the SDK, the `sh.js` tag with `data-property="sh-…"` does the same. See [Analytics](/docs/analytics) and [Reports](/docs/analytics-reports). ## Render a variant ```tsx import { useValue, useTrack } from '@splithub/react'; export function Hero() { const title = useValue('hero-title'); const track = useTrack(); return

track('signup')}>{title}

; } ``` The experiment's goal can be any event your app sends with `track()`: the same call feeds the analytics property and the experiments that measure it, one row per action. `create` writes the experiment as `running`: it goes live for every visitor when you deploy the code that renders it. Check it locally first with `?sh_force=:`; development never sends events to the cloud. To hold an experiment, create it with `--status draft` and start it later with `npx splithub start `, or pause it in the dashboard. A `value` experiment changes data (text, a URL, a number, JSON) without new components. A `component` experiment renders different JSX per variant with `…`. A `redirect` experiment sends each variant to its own page. An `mvt` experiment changes several things at once and reads each one on its own. See [React](/docs/react), [Next.js](/docs/nextjs), [Redirect experiments](/docs/redirect-experiments) and [splithub.config.ts](/docs/config). ## Read the verdict ```bash npx splithub status # every experiment: status, visitors, days, verdict headline; in the cloud also each property's last 7 days npx splithub results hero-title ``` In SplitHub Cloud the verdict is one of `no_data`, `insufficient_sample`, `srm_detected`, `winner_found`, `no_significant_difference`, `guardrail_breached`, with a message and the numbers behind it: visitors and conversions per variant, relative lift, an always-valid p-value and confidence interval, probability to beat, sample quality and a data-quality score of 0 to 5 stars. The test is safe to check any time. The [statistics page](/docs/statistics) explains each part. A self-hosted endpoint answers the raw numbers (visitors, conversions, rate, revenue per variant); verdicts come with SplitHub Cloud. How many visitors will it take? `npx splithub plan --baseline 0.05 --mde 0.1` answers before you start, no account needed. ## Finish ```bash npx splithub finish hero-title npx splithub ship hero-title --keep bold --dry-run # then without --dry-run ``` `ship` finishes the experiment, removes it from the config and rewrites the code to the kept variant; `--dry-run` shows the diff first. In cloud mode a finished experiment stops accepting events at the edge; nothing more is stored or billed. ## Next - [How it works](/docs/concepts) explains properties, assignment, exposures, events and goals. - [splithub.config.ts](/docs/config) lists every option. - [Self-hosted](/docs/self-hosted) if the events must stay in your database. - [SplitHub Cloud](/docs/cloud) for the dashboard, reports, live overrides, alerts and AI reviews. --- # How it works Five ideas explain the whole system. ## The config file is the source of truth `splithub.config.ts` declares goals, analytics properties and experiments. It is committed, reviewed and diffed like the rest of the code. The CLI edits it (`create`, `start`, `pause`, `finish`, `ship`, `analytics create`), the SDK reads it, and in cloud mode `push` uploads a normalized copy so the dashboard and the API can read the same thing. The dashboard never edits the file. Experiments it creates are "cloud-only" until `npx splithub pull` writes them into the file; experiments the file owns are read-only in the dashboard except for pausing and finishing. Live overrides (status, traffic, weights, `value` payloads) sit on top of the config. The browser SDK applies them from a first visit on: it waits briefly for them when nothing is cached and refreshes them in the background on later pages. It never changes a variant a page has shown. Apps are the exception. A config built into an app binary only changes with a store release, so app experiments start and change in the dashboard, and installed apps pick them up on their next launch. Native iOS and Android apps have no config file at all. See [Apps](/docs/apps). ## Properties and experiments An **analytics property** is a stream of pageviews and events, like a site in a web analytics tool: visitors, visits, pages, sources, events, goal conversions, funnels and retention. It has no variants and no verdicts; its reports are counts. Declare it under `properties` in the config, or make it in the dashboard; ids look like `sh-x1h78w`. Events go only to a property named explicitly: `property` on the provider or client, `data-property` on `sh.js`. An **experiment** splits visitors between variants and compares one goal. Both share one install, one visitor id and one `track()` call: with a property on the provider, each `track()` sends one event row and each page one page row, and the experiments whose goal is that event or page count them from those rows. Nothing extra is sent or billed. See [Analytics](/docs/analytics). ## Assignment is a local hash A visitor gets a random id in the `sh_vid` cookie (first party, set by the SDK or the Next.js middleware). Assignment uses MurmurHash3 with the visitor id, experiment key and seed, then selects from the cumulative variant weights. A separate hash controls entry into the traffic share; a holdout and layers, when you use them, are checked before that. Given the same config and targeting context, browser, edge and server agree. Bucketing is local; fetching cloud overrides or hosted script config may require a request. Crawlers see the control and are never counted. Consequences: changing `seed` reshuffles every visitor (never do it on a running experiment); changing `traffic` does not; a visitor keeps the same variant across pages and sessions in the same browser, while the id and experiment settings remain unchanged. ## Exposures, events and goals An **exposure** is "this visitor saw this experiment in this variant". The SDK records it when the variant is actually rendered (`` mounts, `useValue` is read, the redirect fires). An **event** is anything your app sends with `track(name, props?)`. Any name is recorded; nothing to declare first. A **goal** is an event marked as one: in `goals` in the config, with the goal switch on an event in the dashboard, or simply by naming the event as an experiment's goal (`Video Played` becomes the goal `video-played`). A page visit (`pageview` with a path) or a click on a selector can be a goal too. In SplitHub Cloud attribution is decided at ingest: - a visitor counts once per experiment, in the variant of their **first exposure**; a returning visitor is not stored or billed again; - a goal converts the experiments that measure it when it arrives **at or after** that exposure; earlier goals count for nothing; - units are **unique visitors**, not hits; at most 50 of one goal per visitor per day are kept; - goals from visitors who never saw an experiment do not reach any experiment (an analytics property still records the event). Self-hosted stores apply the same rule when they read. In the cloud, exposures of running experiments, pageviews and custom events are billed; goal rows never are. Exposures for paused, finished or draft experiments are dropped at the edge and not stored. ## Self-hosted or cloud | | Self-hosted | SplitHub Cloud | |---|---|---| | Where events go | your database through a `Store` (SQLite, D1, Postgres, MySQL, custom) | `https://go.splithub.io` | | Config line | `storage: { mode: 'self', endpoint: '/api/splithub' }` | `storage: { mode: 'cloud', key: 'pk_…' }` | | Experiment results | raw numbers per variant: visitors, conversions, rate, revenue | verdicts, p-values, stars, guardrails, factor effects, SRM, breakdowns | | Analytics | raw counts: pageviews, visitors, visits, events by name | reports: pages, sources, channels, funnels, paths, retention, realtime | | Where you read it | CLI and MCP against your app's endpoint | CLI, MCP, dashboard, email | | Live overrides | no (edit the file) | yes | | Price | free, unlimited | 50,000 visitors (exposures, pageviews and events) per project per month free, then packs or Pro | The SDK, the CLI and the MCP server are the same in both modes. The analysis (verdicts, stars, guardrails, factor effects, breakdown comparisons, AI reviews) runs only in SplitHub Cloud; a self-hosted endpoint answers with `analysis: { available: false }` next to its numbers. To switch, update `storage` and configure the destination: a handler and database for self-hosting, or a project, keys and config push for cloud. ## Vocabulary - **property**: an analytics stream of pageviews and events, read as reports. - **experiment**: one question with 2+ variants; `kind` is `value`, `component`, `redirect` or `mvt`. - **control**: the first declared variant, what exists today. - **weight**: relative share of traffic per variant (50/50, 1/1, 90/10). - **traffic**: share of visitors who enter the experiment at all (`0.5` = half; the rest see the control and record no exposure). - **conversion**: a visitor who reached the goal, counted once. - **verdict**: the one-line conclusion of the statistics, with its status code. - **SRM**: sample ratio mismatch, the split you got differs from the one you configured; the setup is broken. - **guardrail**: a goal that must not get worse; a variant that hurts one is never the winner. --- # splithub.config.ts The config is a plain TypeScript (or JavaScript) module that exports `defineConfig(...)` from `@splithub/core`. `defineConfig` validates and normalizes it at import time, so mistakes fail fast with a readable message. The file ships to the browser: never put secrets in it. ```ts import { defineConfig } from '@splithub/core'; export default defineConfig({ project: 'my-app', storage: { mode: 'cloud', key: 'pk_live_…' }, runDraftsInDev: true, goals: { signup: { type: 'event', name: 'Sign up' }, purchase: { type: 'event', revenue: true }, 'video-played': { type: 'event', event: 'Video Played', value: 2 }, pricing_view: { type: 'pageview', path: '/pricing' }, cta_click: { type: 'click', selector: '#cta' }, }, properties: { 'sh-x1h78w': { name: 'Marketing site' }, }, layers: { checkout: {} }, holdout: { percent: 5, goal: 'purchase' }, experiments: { 'hero-title': { kind: 'value', status: 'running', goal: 'signup', name: 'Hero headline', description: 'Feature-led vs outcome-led headline', metrics: { secondary: ['pricing_view'], guardrails: ['purchase'] }, variants: { control: { weight: 50, value: 'Ship experiments from your terminal' }, outcome: { weight: 50, value: 'Know which version wins, by Friday' }, }, }, 'pricing-cta': { kind: 'component', status: 'draft', goal: 'purchase', traffic: 0.5, variants: { control: 1, 'see-plans': 1, 'talk-to-us': 1 }, maxExposures: 20000, layer: { key: 'checkout', range: [0, 50] }, schedule: { startAt: '2026-11-01T00:00:00Z', endAt: '2026-11-30T00:00:00Z' }, }, 'hero-combo': { kind: 'mvt', status: 'running', goal: 'signup', factors: { headline: { old: 'Ship experiments', new: 'Know what wins' }, button: { blue: '#2563eb', green: '#16a34a' }, }, }, 'landing': { kind: 'redirect', status: 'running', goal: 'signup', match: '/', variants: { control: { weight: 50, to: '/' }, 'new-landing': { weight: 50, to: '/start' } }, }, }, }); ``` ## Top level | Field | Required | Meaning | |---|---|---| | `project` | yes | Name shown in the CLI and the dashboard. | | `storage` | yes | `{ mode: 'self', endpoint }` or `{ mode: 'cloud', key, endpoint? }`. | | `goals` | no | Map of goal key → goal. | | `properties` | no | Map of property id → analytics property. | | `experiments` | yes | Map of experiment key → experiment. | | `layers` | no | Map of layer key → `{ seed? }`, for experiments that must never share a visitor. | | `holdout` | no | `{ percent, seed?, goal? }`: a share of visitors kept out of every experiment. | | `visitorKey` | no | Cookie name for the visitor id, default `sh_vid`. | | `runDraftsInDev` | no | `true` serves `draft` experiments like running ones while the app runs in development. Default `false`. | `storage.endpoint` in self mode is the path (or URL) where the `@splithub/node` handler is mounted. In cloud mode `key` is the project's public key; `endpoint` defaults to `https://go.splithub.io/v1/events` and only changes for a self-hosted cloud or staging. ## Drafts in development A `draft` experiment is invisible to visitors: everyone sees the control and nothing is recorded. To see it in your own app before you start it, set `runDraftsInDev: true`. While `NODE_ENV` is `development` (`next dev`, `vite`), drafts behave exactly like running experiments: variants by visitor, targeting, redirects, exposures and goals. A production build ignores them as before, so the same config is safe to deploy. In development the schedule of a draft is ignored, so an experiment that starts next week can be checked today. In cloud mode development never sends events to the cloud, for drafts and running experiments alike, so your own visits never reach the results and nothing is billed. Deployed builds, and `vite preview`, send as usual. While nothing in the project is running (no experiment, no property) the SDK holds events back too, because the cloud would drop them. In development the SDK also explains itself, one line per experiment: a draft that everyone sees as the control, a draft shown because of this option and which variant you got, a paused experiment, events held back. Next.js server code prints it in the terminal that runs `next dev`. In a Vite app the SDK runs in the browser, so add the plugin to see it in the terminal that runs `vite`: ```ts // vite.config.ts import { splithub } from '@splithub/core/vite'; export default defineConfig({ plugins: [react(), splithub()] }); ``` On start and on every save of the config it prints each experiment and what it does in development, then forwards the SDK's notes from the browser. Without the plugin the notes stay in the browser console. Production builds print nothing. With `?sh_debug=1` the debug panel shows such assignments with the reason `dev`; its "new visitor" button lets you walk through every variant. `npx splithub init` turns the option on for new projects. ## Goals A goal is an event marked as one. You do not have to declare event goals: an experiment's `goal` (or a metric) may name any event, and a name that is not in `goals` becomes an event goal keyed by its slug (`Video Played` → `video-played`, with `event: 'Video Played'`). Declare a goal when you want a label, a value, revenue, a page visit or a click. | Type | Fields | Fires when | |---|---|---| | `event` | `revenue?: boolean`, `event?: string` | your code calls `track('', props?)`, or `track('')` when `event` is set; with `revenue: true` the `props.revenue` number is summed per variant | | `pageview` | `path` | a page with that path is viewed (exact match, or prefix with a trailing `*`) | | `click` | `selector` | an element matching the CSS selector is clicked (the SDK and the drop-in script bind it) | Every goal also takes `name` (a label for people; the key stays what the code sends), `description`, and `value` (what one conversion is worth in analytics reports when the event carries no `revenue` prop). `event` names the event when it is not a valid key, like `'Video Played'` for `video-played`. Goals count as conversions in every analytics property unless a property's `goals` narrows them. Goals created in the dashboard reach cloud clients; add their definitions to the local file yourself, `pull` adopts experiments, not goals. ## Properties ```ts properties: { 'sh-x1h78w': { name: 'Marketing site', goals: ['signup', 'purchase'] }, } ``` | Field | Default | Meaning | |---|---|---| | `name` | the id | shown in the dashboard and the CLI (up to 80 characters) | | `status` | `running` | `running` or `paused`; a paused property's events are dropped at ingest | | `goals` | every goal | goal keys this property reports as conversions | | `description` | none | free-form note | | `kind` | `site` | `site` or `app` (screens, versions, retention; see [Apps](/docs/apps)) | `npx splithub analytics create --name "Marketing site"` or `analytics connect` writes the entry with a generated id and, in cloud mode, creates it in the cloud at once. Properties made in the dashboard need no entry in the file. See [Analytics](/docs/analytics). ## Experiments Keys are lowercase `a-z 0-9 - _`, 1–64 characters, and start with a letter or digit. The **first variant is the control**. | Field | Default | Meaning | |---|---|---| | `kind` | required | `value`, `component`, `redirect` or `mvt` | | `status` | required | `draft`, `running`, `paused`, `finished` | | `variants` | required (not for `mvt`) | see below | | `factors` | required for `mvt` | factor → level → value, see below | | `goal` | none | the goal the verdict is computed on: a key from `goals` or any event name | | `metrics` | none | `{ secondary?: [goal], guardrails?: [goal \| { goal, worse: 'higher' }] }`, read beside the primary goal | | `traffic` | `1` | share of visitors (0–1) entering the experiment; others see the control without an exposure | | `seed` | the key | hash seed; never change it on a running experiment | | `name` | the key | human name shown in the dashboard and the CLI (up to 80 characters); rename it freely, the key and the data stay | | `description` | none | shown in the dashboard and in emails | | `match` | required for `redirect` | path the redirect applies to (exact, or prefix with trailing `*`) | | `maxExposures` | none | the cloud finishes the experiment when stored exposure events reach this number | | `schedule` | none | `{ startAt?, endAt? }` ISO dates; outside the window the control is served and nothing is recorded | | `layer` | none | `{ key, range: [start, end] }`: the buckets 0–99 of a declared layer this experiment enrolls, end excluded | | `minDays` | `0` | opt-in minimum runtime in days before a verdict, whole number 0–30 | | `goodSample` | `5000` | total visitors at which the sample counts as good, 100–10,000,000; informational | | `flow` | none | targeting graph, see [Targeting flows](/docs/targeting) | ### Variants by kind ```ts // component: weights only, JSX lives in the app variants: { control: 1, bold: 1 } // value: weight + payload (string, number, boolean, object, array) variants: { control: { weight: 50, value: 'Old' }, bold: { weight: 50, value: 'New' } } // redirect: weight + destination variants: { control: { weight: 50, to: '/' }, v2: { weight: 50, to: '/home-v2' } } ``` Weights are relative: `{ a: 1, b: 1 }` and `{ a: 50, b: 50 }` are the same split; `{ a: 90, b: 10 }` is a 90/10 split. ### Multivariate (`mvt`) ```ts factors: { headline: { old: 'Ship experiments', new: 'Know what wins' }, button: { blue: '#2563eb', green: '#16a34a' }, } ``` Every combination becomes a variant with an equal weight: `old-blue`, `old-green`, `new-blue`, `new-green`. The combination of every factor's first level is the control. `useValue('hero-combo')` returns the combination's values, `{ headline, button }`. At most 4 factors, 4 levels each and 16 combinations. Results show the combinations and, below them, each factor read on its own. Every combination needs the minimum sample by itself, so an `mvt` experiment needs more traffic than a two-variant one. The CLI writes `factors`, never the derived variants. ### Metrics and guardrails `secondary` goals are compared with the control the same way as the primary goal, for information. A `guardrail` must not get significantly worse: a variant that hurts one is never called the winner, and the verdict becomes `guardrail_breached`. A guardrail is worse when its conversion rate drops; use `{ goal: 'refunds', worse: 'higher' }` for goals where more is bad. In the cloud, metric goals are counted from the moment they are pushed, with no backfill. See [Statistics](/docs/statistics). ### Layers and holdout ```ts layers: { checkout: {} }, experiments: { 'checkout-steps': { /* … */ layer: { key: 'checkout', range: [0, 50] } }, 'checkout-copy': { /* … */ layer: { key: 'checkout', range: [50, 100] } }, } ``` Experiments in one layer never share a visitor. Each visitor falls in one bucket 0–99 per layer, the same for every experiment of that layer, and an experiment enrolls only the buckets in its range. Ranges are explicit, so adding an experiment never moves the visitors of another. Overlapping ranges of unfinished experiments in one layer fail `defineConfig`. A visitor outside the range sees the control without an exposure. `seed` on the layer reshuffles its buckets. `holdout: { percent: 5 }` keeps that share of visitors (at most 50) on the control in every experiment, with no exposures. Everyone who reaches a running experiment is counted in the reserved comparison `__holdout` (`holdout` against `enrolled`), which measures the long-term effect of all your experiments together. `goal` is its primary goal (any event name; default: the first declared goal); the other goals are read as secondary metrics. `npx splithub holdout set 5 [--goal purchase]` writes it, `holdout off` removes it. Holdout and layers are checked before traffic and the targeting flow, identically in the SDK, the Next.js middleware and the drop-in script. ## What the CLI changes `create` adds an experiment (with `--factor`, `--secondary`, `--guardrail`, `--layer`, `--min-days`, `--good-sample`), `start`/`pause`/`finish` change `status`, `ship` and `remove` take an experiment out, `holdout` sets the holdout, `analytics create` and `analytics connect` add a property, `pull` adds missing dashboard-created experiments (including flows) but not goal definitions, `init` writes the file the first time. Edits keep your formatting where possible; the file stays a normal TypeScript module you can edit by hand. ## Rules - Never change `seed`, `variants` or weights of a running experiment; create a new one. - Never reuse the key of an earlier experiment: the key is its id for good. - Keep one `id` for things that must change together, separate ids for independent questions. - On low traffic run at most 2–3 experiments per page. - Keys that exist in the file are owned by the file: the dashboard cannot edit them (it can pause or finish them). - In cloud mode a status set in the dashboard wins over the file's `status`, from a visitor's first page, until you clear it. The dashboard shows both when they differ. --- # Analytics An analytics property is a stream of pageviews and custom events, like a site in Google Analytics. It lives next to your experiments: the same config file, the same SDK or script, the same CLI and key, and the same storage choice (SplitHub Cloud or your own database). A property has no variants and no verdicts; its reports are counts. How to read them: [Reports](/docs/analytics-reports). ## Properties Each property has a generated id like `sh-x1h78w`. Events go only to a property named by its id, never to a guess, so a page without a property sends no analytics at all. A property comes from one of two places: - **The config file.** Self-hosted projects keep their properties here; cloud projects may too. ```ts // splithub.config.ts properties: { 'sh-x1h78w': { name: 'Marketing site' }, }, ``` | Field | Default | Meaning | |---|---|---| | `name` | — | shown in the dashboard and the CLI, up to 80 characters | | `description` | — | a note for your team | | `status` | `running` | `running` or `paused` | | `goals` | every goal | goal keys counted as conversions in this property | | `kind` | `site` | `site` for a website, `app` for a mobile or desktop app (screens, versions, retention; see [Apps](/docs/apps)) | - **The dashboard** (Analytics → New property). Its id is on the property's page. It needs no push and no change to the file. ## Set it up One command picks the project's property (or creates one) and adds it to the provider in your code: ```bash npx splithub analytics connect # or: Analytics → Set up in the npx splithub menu npx splithub analytics connect --name "Marketing site" # create one with this name npx splithub analytics connect sh-x1h78w # use this id ``` It finds the one `` or `` in your code and adds `property="sh-…"`. When there is none, or several, it prints the snippet to add yourself. In cloud mode the property is created in the cloud at once, so the next deploy collects: no `push` needed. To only write the property into the file, use `npx splithub analytics create --name "Marketing site"`. In a React Native or Expo project the property is made as an app property (`kind: 'app'`), and screens take the place of pages. Never `push` just to add analytics. A push uploads the whole file, and an experiment the cloud knows from an earlier push that the file lacks would be removed (`push --dry-run` lists what would change). ### React and Vite ```tsx ``` Details: [React](/docs/react#analytics). ### Next.js ```tsx {children} ``` Details: [Next.js](/docs/nextjs#analytics). ### Any site, no build step ```html ``` Details: [Drop-in script](/docs/script#analytics). ## Pageviews With a property, a pageview is recorded on load and on every client-side navigation: `history.pushState`, `replaceState`, back and forward, and hash routes. A new path, query or hash is a new page. You do not call anything for a single-page app. Each page also reports time on page and scroll depth, which the reports turn into visit duration and engagement. Turn the automatic navigation tracking off with `trackNavigation: false` on the provider (or `createClient`) and report pages yourself with `usePageview(path)` or `client.pageview(path)`. ## Events `track` is the one call for events, in every install: ```tsx const track = useTrack(); // React and Next.js track('Video Played', { id: 3 }); track('checkout', { revenue: 49 }); ``` ```js splithub.track('Video Played', { id: 3 }); // sh.js ``` - **Names**: letters, digits, spaces and `_ . : / -`, up to 64 characters. Any name is recorded; there is nothing to declare first. - **Props**: a flat object of up to 4 KB of JSON. Larger props keep `revenue` and the plain values that fit, with a console warning. - `revenue` is the one prop with a built-in meaning: it becomes the revenue of a goal. - Only what your code passes to `track()` is sent. Fire events on actions, never in a render loop or on every scroll: each one is stored and billed. ## Identify After sign-in, tie the visitor to your own user id: ```tsx const identify = useIdentify(); identify(user.id, { plan: 'pro' }); // after sign-in identify(null); // on sign-out ``` `splithub.identify(id, traits)` does the same from sh.js. The id is 1 to 128 characters; traits are flat text, number and true/false values up to 4 KB. The browser remembers the id and sends it again once per session. Reports then count `users` next to visitors, and you can break any report down by a trait (`--by trait --key plan`). Use an internal id, never an email or a name. ## Events and goals Every event is recorded. A **goal** is an event you marked as one: - a goal in `goals` of the config (see [Goals and revenue](/docs/goals)), - the goal switch on an event in the dashboard (Settings → Events), or - naming the event as an experiment's goal: `Video Played` becomes the goal `video-played`. Goals count as conversions in every property unless the property's `goals` list narrows them. A goal's `value` is what one conversion is worth when the event carries no `revenue` prop; `event: 'Video Played'` maps a goal key to an event name that is not a valid key. ## One install for analytics and experiments The provider (or sh.js) with a `property` collects pageviews and runs experiments. Each `track()` sends one event row and each page one page row. Experiments whose goal is that event or that page count their conversions from those same rows, so nothing extra is sent or billed. Keep one install per property. If sh.js and the SDK both run on a page for the same property, the first one to send a page collects pageviews, time on page and identify; the other sends only its own `track()` calls. ## Development A development build (`NODE_ENV=development`) sends nothing to SplitHub Cloud, so your own visits never reach the reports. The console says so once, and `?sh_debug=1` logs each row held back. Check tracking on a deploy or with `vite preview`, then list the latest raw rows: ```bash npx splithub analytics events sh-x1h78w ``` ## Allowed domains Allowed domains are set per property, like per experiment. An empty list accepts every origin. ```bash npx splithub domains set property sh-x1h78w example.com # subdomains included npx splithub domains clear property sh-x1h78w # any origin again npx splithub domains # every list ``` Pageviews and events from another origin are dropped; a batch with nothing allowed answers `403`. Add `localhost` when you test against the cloud from it. An app sends no web origin, so its app id stands in: add `com.acme.shop` to the list (exact match). A list with only websites refuses apps. ## Pause, archive, remove ```bash npx splithub analytics pause sh-x1h78w # stop collecting; resume with: analytics resume sh-x1h78w npx splithub analytics archive sh-x1h78w # stop collecting and leave the list, numbers kept npx splithub analytics restore sh-x1h78w # back in the list, paused npx splithub analytics remove sh-x1h78w # out of the config ``` Pause and resume take effect in the cloud within a minute; events of a paused property are dropped and not billed. Archive and restore are SplitHub Cloud features, the same as Archive in the dashboard. `remove` takes a property out of the file (push next); removing a property made in the dashboard deletes it with its numbers. ## What is billed Every pageview and every custom event of a running property counts like one exposure against the project's allowance. Time on page, scroll depth and identify are not billed, and neither are the goal conversions experiments count from your events. See [Plans and limits](/docs/pricing). ## Privacy - Bots are never counted: known crawlers are turned away at the edge and bot user agents never reach analytics. - Keep personal data out of props and traits: no emails, names or ids from other systems. - The visitor id is a random first-party cookie (`sh_vid`), or a random id in the app's own storage; identify links it to your user id only when you call it. ## Self-hosted With `storage: { mode: 'self' }` the rows go to your own database through the [ingest handler](/docs/self-hosted#analytics). The same commands answer raw counts for a range of UTC days: pageviews, visitors, visits, identified users and events by name. ```bash npx splithub analytics sh-x1h78w --days 7 npx splithub analytics sh-x1h78w --from 2026-10-01 --to 2026-10-31 ``` Reports with pages, sources, channels, visits, conversions, filters, `--by` and `--compare`, plus funnels, paths and retention, come with SplitHub Cloud. --- # Reports Everything an [analytics property](/docs/analytics) collects can be read in three places: the dashboard, the `splithub` CLI and the MCP server. They answer from the same reports, so an agent and a teammate looking at the dashboard see the same numbers. Reports are counts, not experiment results: for a verdict use `npx splithub results`. Reports with dimensions, filters, visits and conversions are SplitHub Cloud features. A self-hosted property answers raw counts (see [Self-hosted](/docs/analytics#self-hosted)). ## Where to read them | Report | Dashboard (property page) | CLI | MCP tool | |---|---|---|---| | Every property, last 30 days | Analytics | `npx splithub analytics` | `splithub_analytics` | | Totals, goals, top pages and sources | Overview | `analytics ` | `splithub_analytics` with `property` | | By a dimension | Pages, Sources, Audience, Events & goals | `analytics --by ` | `splithub_analytics` with `by` | | Funnels | Funnels | `analytics funnel ` | `splithub_analytics_funnel` | | Paths | Paths | `analytics paths ` | `splithub_analytics_paths` | | Retention | Retention | `analytics retention ` | `splithub_analytics_retention` | | Right now | Realtime | `analytics realtime ` | `splithub_analytics_realtime` | | Latest raw rows | Raw events | `analytics events ` | `splithub_analytics_events` | | Event props | Events & goals | `analytics props --event ` | — | | CSV | Export | `analytics export ` | — | | Notes | Notes | `npx splithub notes` | `splithub_notes` | | Saved segments | the filter bar | `analytics segments` | `splithub_segments` | Every CLI command takes `--json`. `npx splithub status` also lists each property's last 7 days against the 7 before, and `npx splithub report` adds them as an Analytics table. ## Totals ```bash npx splithub analytics sh-x1h78w --days 7 npx splithub analytics sh-x1h78w --from 2026-10-01 --to 2026-10-31 ``` Visitors, new visitors, identified users, visits, pageviews, events, bounce rate and visit duration, then each goal (conversions, completions, revenue), the top pages, the top sources and the notes in the range. The default range is the last 30 days; one report covers at most 400 days. Days follow the project's time zone (Settings). ## Dimensions `--by` groups the report by one dimension: | Group | Dimensions | |---|---| | Pages | `page`, `entry`, `exit` | | Sources | `source`, `referrer`, `channel`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | | Audience | `country`, `region`, `city`, `device`, `os`, `browser`, `language`, `host` | | Apps | `platform` (`ios`, `android`, `web`…), `app_version` | | Actions | `event`, `goal` | | Your data | `trait --key plan` (an identify trait), `variant --experiment hero` (the variant each visitor saw) | ```bash npx splithub analytics sh-x1h78w --by channel npx splithub analytics sh-x1h78w --by source --goal signup # conversions of signup per source npx splithub analytics sh-x1h78w --by variant --experiment hero npx splithub analytics sh-x1h78w --by trait --key plan ``` With a visit dimension, `--goal ` adds that goal's conversions per row. ## Filters `--filter dimension=value[,value]` narrows the visits a report starts from. Repeat it to combine filters; several values of one filter mean any of them. ```bash npx splithub analytics sh-x1h78w --by page --filter country=DE,FR --filter source="(none)" ``` Any visit dimension, `page` and `event` filter this way. Four prefixes reach your own data: | Filter | Keeps visits | |---|---| | `p.gclid=abc` | whose landing page had that query parameter | | `t.plan=pro` | of users with that identify trait | | `e.signup.plan=pro` | that sent the event `signup` with that prop value | | `x.hero=bold` | of visitors who saw that variant of the experiment | ## Compare ```bash npx splithub analytics sh-x1h78w --days 7 --compare # the 7 days right before npx splithub analytics sh-x1h78w --days 7 --compare year # the same days a year earlier npx splithub analytics sh-x1h78w --compare 2026-08-01..2026-08-31 # days you choose ``` The answer gains `previous`, and the CLI prints the change per row. ## Segments A segment is a saved set of filters that every report can apply. ```bash npx splithub analytics segments --save "German traffic" country=DE npx splithub analytics sh-x1h78w --segment "German traffic" --by page npx splithub analytics segments # the list npx splithub analytics segments --delete "German traffic" ``` `--property ` keeps a segment to one property. A `--filter` on the same dimension wins over the segment's. ## Funnels ```bash npx splithub analytics funnel sh-x1h78w --steps "page:/,event:Signup,goal:paid" npx splithub analytics funnel sh-x1h78w --steps "page:/pricing,goal:paid" --within 7d --by country ``` Two to eight steps: `page:/path` (patterns like `page:/blog/*` work), `event:` or `goal:`. Visitors count for a step only after the steps before it. `--within` is `visit` (the default, all in one visit), `7d` or `30d` from the first step. Each step answers `visitors`, `conversion` (share of step 1) and `dropoff` (share lost since the step before). ## Paths ```bash npx splithub analytics paths sh-x1h78w --page /pricing # where visits went next npx splithub analytics paths sh-x1h78w --page /pricing --prev # where they came from ``` Rows are pages with their visits; `(exit)` means the visit ended there, `(entrance)` that it started on the page. ## Retention ```bash npx splithub analytics retention sh-x1h78w npx splithub analytics retention sh-x1h78w --start event:Signup --return visit --period day ``` Visitors are grouped by the day or week they started (`--start visit`, `event:` or `goal:`), and each cohort shows how many came back (`--return visit` or `event:`) in each period after. The default is visit to visit, by week. With `--json` the answer is `cohorts[] { start, users, retained[] }`: `retained[0]` is the cohort period itself, `retained[n] / users` the share back in period n. One report covers up to 90 days or 26 weeks (`clamped: true` when cut). For an app this is the report to watch; screens count as visits like pages do (see [Apps](/docs/apps)). ## Realtime ```bash npx splithub analytics realtime sh-x1h78w ``` Visitors in the last 5 minutes, pageviews and events per minute over the last 30 minutes, top pages and sources. The quickest check that tracking works after a deploy. ## Event props ```bash npx splithub analytics props sh-x1h78w --event Signup # the prop names of Signup npx splithub analytics props sh-x1h78w --event Signup --key plan # the values of one prop ``` ## Export ```bash npx splithub analytics export sh-x1h78w --by day > daily.csv npx splithub analytics export sh-x1h78w --by page > pages.csv npx splithub analytics export sh-x1h78w --raw > events.csv ``` CSV to stdout, for the range and filters you pass. `--raw` exports the raw tail (see [Retention](/docs/cloud#retention)). ## Notes A note marks a day on every chart, so a jump in traffic has its reason next to it. ```bash npx splithub notes add "Launched new pricing" --kind win --property sh-x1h78w --day 2026-10-05 npx splithub notes --property sh-x1h78w ``` Add one whenever you ship something that may move traffic. ## Alerts Alerts on a property are a Pro feature, set in the dashboard (Alerts) or with the CLI: ```bash npx splithub alerts add traffic_drop --property sh-x1h78w --percent 50 npx splithub alerts add traffic_spike --property sh-x1h78w --percent 50 npx splithub alerts add goal_silent --property sh-x1h78w --goal signup --hours 24 npx splithub alerts add event_count --property sh-x1h78w --event Signup --count 1000 --repeat ``` The digest covers properties too: visitors, top sources and pages, goals and events against the period before. Channels and schedules: [Alerts, digest, AI reviews](/docs/alerts). ## Words | Word | Meaning | |---|---| | visitor | one browser, by its `sh_vid` cookie | | user | a visitor you identified with `identify()` | | visit | a run of pageviews and events that ends after 30 minutes without any (`sessions` in JSON) | | bounce | a visit with one page and no event; bounce rate is bounces divided by visits | | duration | time in a visit, in milliseconds in JSON | | source | the UTM source, or the referrer's site (Google, Facebook…); `(none)` is direct | | channel | sources grouped: Direct, Organic Search, Paid Search, Organic Social, Paid Social, Email, Referral, Affiliate, Other | | entry, exit | the first and the last page of a visit | | conversion | a visitor reaching a goal, counted once per visitor in the range | | completions | how many times the goal happened | Bots are never counted. ## For agents The [analytics skill](/docs/agent-skills) that `npx splithub skills` installs teaches an agent these commands. Ask it in plain words ("where did signups come from last week?", "how many visitors reach checkout from pricing?") and it picks the report, the range and the filters, and quotes the counts as they are. --- # Redirect experiments A redirect experiment tests whole pages: visitors who match a path are sent to a different URL per variant. No component code is involved, so it works for a redesign, a new landing page, a different checkout flow, or two pages built in a no-code tool. ```ts 'landing': { kind: 'redirect', status: 'running', goal: 'signup', match: '/', variants: { control: { weight: 50, to: '/' }, 'new-landing': { weight: 50, to: '/start' }, }, } ``` `match` is a path, exact or a prefix with a trailing `*` (`/pricing*`). `to` is the destination per variant; the control usually points at the matched page itself. Use same-site paths for Next.js middleware rewrites. The drop-in script and short links can navigate to absolute URLs on another host. ## Three ways to serve it | Where | How | Flash | |---|---|---| | Next.js | `splithubMiddleware` rewrites the request at the edge; the address bar keeps the original URL | none | | Drop-in script | `sh.js` assigns and navigates; put `` before the script | rendering held for up to 1,500 ms by default | | Short links | a SplitHub link with two weighted destinations (no config needed) | a 302, like any short link | The middleware and the script decide the same way as the SDK: same hash, same targeting flow, and the [holdout and layers](/docs/config) checked first. A held-out visitor, or one outside the experiment's layer range, gets the control and records no exposure; every visitor who reaches a running redirect experiment still counts in the holdout comparison. A draft redirect is served in development when the config has `runDraftsInDev: true`, by the middleware and by the script alike. `?sh_force=landing:new-landing` previews one destination of a running experiment. ## Creating one From the CLI: `npx splithub create --kind redirect --id landing --variants control,new-landing --match / --to control=/ --to new-landing=/start --goal signup --yes`. From the dashboard: New experiment → Redirect. It asks for the page to test and the variant pages, and offers "create with a link" for the short-link variant. Experiments created there are cloud-only until `npx splithub pull` writes them into the file. ## Goals on the destination pages The goal has to fire on the page the visitor lands on. A `pageview` goal with `path: '/thanks'` works everywhere. With the script, `click` goals bind on any page; `event` goals need `splithub.track('…')` on the destination. The visitor id is in the first-party cookie, so attribution crosses pages on the same site. Cookies are not shared across unrelated domains, and the SDK does not import visitor ids from `?sh_vid=`. Cross-domain conversion attribution needs a custom identity integration; short-link click stats work independently. ## Pitfalls - Caching: a CDN that caches the HTML of `/` for everyone cannot serve per-visitor redirects. The middleware route and the script both run per visitor; make sure the matched page is not fully static-cached, or use the script. - Loops: do not match the destination path of a variant with the same experiment (`match: '/start*'` while `to: '/start'`). - Crawlers: bots and headless browsers always get the control and are never counted; `sh.js` never holds or redirects them. --- # Targeting flows Who enters an experiment, and with which split, can depend on country, device, browser, UTM parameters, cookies, time, the app's platform and version, or whether the visitor is new or returning. In SplitHub this is a small node graph, the **flow**, on the experiment. ## In the dashboard Open an experiment → Targeting. The editor has three node types: - **Condition**: one or more conditions, combined with `match: 'all'` or `'any'`. Fields include `country`, `region`, `city`, `device`, `browser`, `os`, `language`, `referrer`, `url`, `path`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `query`, `cookie`, `visitor`, `hour`, `weekday`, `platform`, `app_version`. For `query` and `cookie`, set `key` to the parameter or cookie name. Operators: `is`, `is_not`, `in`, `not_in`, `contains`, `not_contains`, `starts_with`, `matches`, `exists`, `not_exists`, plus `gte` (at least) and `lt` (below) for `app_version` and `hour`. Values are strings; `in` uses a comma-separated list. - **Split**: weighted outputs; each output goes to a variant or to another node. - **Variant** / **Skip**: the leaf. `Skip` means "not in the experiment": the visitor sees the control and no exposure is recorded. A toggle switches to a **simple list** view (sections top to bottom, drag to reorder) for flows that are just "if country in EU → split A, else → split B". While the flow editor is open, variant editing is locked so the two cannot drift; on save the editor collapses to a one-line summary. ## In the config The same graph serializes as `flow` on the experiment and comes into `splithub.config.ts` with `npx splithub pull`: ```ts 'hero-title': { kind: 'value', status: 'running', variants: { control: { weight: 1, value: 'A' }, bold: { weight: 1, value: 'B' } }, flow: { start: 'geo', nodes: { geo: { type: 'condition', conditions: [{ field: 'country', op: 'in', value: 'DE,AT,CH' }], then: 'eu', else: 'rest' }, eu: { type: 'split', outputs: [{ weight: 50, to: 'control' }, { weight: 50, to: 'bold' }] }, rest: { type: 'split', outputs: [{ weight: 90, to: 'control' }, { weight: 10, to: 'bold' }] }, control: { type: 'variant', variant: 'control' }, bold: { type: 'variant', variant: 'bold' }, }, }, } ``` `defineConfig` validates the flow: node references, supported fields and operators, split weights, and declared variant names are checked. Design each path to end in a variant or skip; avoid cycles. ## Apps: platform and version `platform` is `ios`, `android`, `macos`, `windows`, `linux`, or `web` for a browser. `app_version` is the app's version (`2.4.1`) and is empty on websites. Versions compare part by part, so `2.10.0` is at least `2.9`: ```ts conditions: [{ field: 'app_version', op: 'gte', value: '2.4' }] // the dashboard reads it as "App version at least 2.4" ``` Gate an app experiment on the first version that ships its variant code; older builds see the control and are not counted. See [Apps](/docs/apps). ## Where it runs Everywhere the assignment runs, with the same answer: - the React SDK (browser context: device, browser, path, query, cookies, time in the visitor's time zone; country, region and city from the cloud's `ctx` when in cloud mode); - the Next.js middleware (request headers and Cloudflare/Vercel geo); - the drop-in script `sh.js`, which walks the same flow as the SDK; - the React Native, iOS and Android SDKs (platform, app version, language, device; country from the cloud); - server code using `assign(config, experimentId, visitorId, { context })`. Pass supported context fields with `createClient(config, { context: { country: 'DE' } })` or the provider's `context` prop. For a plan stored in a cookie, use `{ field: 'cookie', key: 'plan', op: 'is', value: 'pro' }`. ## Order of checks For each visitor and experiment the SDK, the middleware and `sh.js` decide in the same order: 1. a `?sh_force=:` preview wins and records nothing; 2. crawlers see the control and are never counted; 3. the experiment must be running and inside its schedule; 4. a visitor in the [holdout](/docs/config) sees the control (reason `holdout`); 5. a visitor outside the experiment's [layer](/docs/config) range sees the control (reason `layer`); 6. the `traffic` share, then the flow: a `Skip` leaf gives the control without an exposure (reason `targeting`). So a flow only ever sees the visitors the holdout and the layer let in. `?sh_debug=1` shows the reason for each decision. ## When to use it - Roll a change out to one market first. - Keep a mobile-only variant out of desktop traffic. - Test only paid traffic (`utm_medium is cpc`). - Exclude employees by cookie. - Run an app experiment only on the versions that have its code. Remember the statistics: targeting narrows the sample. The verdict is about the visitors who entered, not about everyone. When conditions decide which variant a visitor reaches, the expected split depends on who visits, so the split check (SRM) is skipped and the verdict notes say so. --- # Events, goals and revenue An event is anything your app sends with `track(name, props?)`. A goal is an event marked as one, and it is what an experiment is judged on. Each experiment names one goal; the verdict is computed on it. Other goals can be read beside it as [secondary metrics or guardrails](/docs/config). ## Events ```tsx const track = useTrack(); track('Video Played', { id: 3 }); ``` One call for every event: React `useTrack`, `client.track` in `@splithub/core`, `window.splithub.track` with the drop-in script. Any name is recorded; nothing to declare first. Names use letters, digits, spaces and `_ . : / -`, up to 64 characters. With an analytics property on the provider, every event shows up in its reports; see [Analytics](/docs/analytics). ## Goals Name any event as an experiment's goal (`--goal "Video Played"` on `create`) and it is a goal: a name that is not in `goals` becomes an event goal keyed by its slug (`video-played`). Declare a goal in the config when you want a label, a value, revenue, a page visit or a click: ```ts goals: { signup: { type: 'event', name: 'Sign up' }, purchase: { type: 'event', revenue: true }, 'video-played': { type: 'event', event: 'Video Played', value: 2 }, pricing_view: { type: 'pageview', path: '/pricing' }, docs_any: { type: 'pageview', path: '/docs/*' }, cta_click: { type: 'click', selector: 'a[href="/start"]' }, } ``` | Type | Fires when | |---|---| | `event` | your code calls `track('signup')`, or `track('Video Played')` for a goal with `event: 'Video Played'` | | `pageview` | a page with that path is viewed; `*` suffix matches a prefix | | `click` | an element matching the selector is clicked; bound automatically by the SDK and the script | A click goal is sent as a goal when the element is clicked; raw clicks are not recorded as events. `name` is a label for people, `value` is what one conversion is worth in analytics reports when the event carries no `revenue` prop. Goals count as conversions in every analytics property unless the property's `goals` list narrows them. Goals can also be made in the dashboard (Settings → Events, the goal switch on an event, or "+ New goal" when creating an experiment) and are delivered to cloud clients. `pull` does not write goal definitions into the file; add them yourself if you want them there. A goal in use cannot be deleted. ## Revenue ```tsx track('purchase', { revenue: 49 }); ``` With `revenue: true` on the goal, results include revenue per variant, and in SplitHub Cloud revenue per visitor (visitors who did not pay count as 0) compared with the control by Welch's t-test. Read it through the API or `results --json`. The verdict stays on the conversion rate; to make sure a variant that converts more does not earn less, add the revenue goal as a guardrail. ## Attribution A visitor converts for a variant when they sent the goal **at or after their first exposure** to that experiment. In SplitHub Cloud this is decided at ingest; self-hosted stores apply the same rule when they read. Units are unique visitors: ten purchases by one visitor are one conversion (and the summed revenue). At most 50 of one goal per visitor per day are kept. A goal sent before any exposure counts for nothing, which is the right behavior for "did the change cause it". Earlier goals are not retroactively attributed to a later exposure. In cloud mode ingest accepts events while the project has a running experiment or property and quota left. Goals from visitors who never saw an experiment do not reach any experiment; an analytics property still records the event. ## Props `track(name, props)` accepts a flat JSON object up to 4 KB (4,096 characters when JSON-serialized); above that, the SDK keeps what fits, `revenue` first. `revenue` is the only key with built-in meaning; the rest is stored with the event and shown in analytics reports, the raw-events view and the CSV export. Keep personal data (emails, names) out of props. ## Pageviews in SPAs The provider sends a pageview on mount, and the client reports every client-side navigation (history and hash routes) as a pageview after that, so pageview goals fire on route changes without extra code. `usePageview(path)` (React) or `client.pageview(path)` reports a page by hand, for example when navigation tracking is turned off with `trackNavigation: false`. ## Good goals - One primary goal per experiment, decided before the experiment starts. - As close to the change as makes sense: a headline experiment on the home page is judged on "clicked the primary button" or "signed up", not on "paid 30 days later". Put the later outcome in `metrics`. - Counts of unique visitors, not of hits. Repeated clicks by one visitor do not make a variant better. --- # Statistics and verdicts SplitHub Cloud turns per-variant counts into a verdict. The same analysis answers the CLI, the MCP server, the dashboard, the reports and the emails, so every surface quotes the same numbers. A self-hosted endpoint returns the raw counts (visitors, conversions, rate, revenue per variant) with `analysis: { available: false }`; verdicts, stars and the rest of this page come with SplitHub Cloud. ## Peeking-safe by design Agents and people check results all the time. A classic p-value read on every check and acted on at the first significant moment inflates false positives several times over. SplitHub decides with an **always-valid sequential test** (a mixture sequential probability ratio test): its p-value and confidence interval stay valid however often you look, so you can check any time and stop as soon as the verdict says so. Verdicts depend on sample size, not on days. ## What is computed For each variant: unique exposed visitors, converted visitors, rate, revenue (when the goal has it), and the chance to be the best variant. For each non-control variant against the control: | Field | Meaning | |---|---| | `absoluteLift` | rate − control rate, as a fraction; multiply by 100 for percentage points | | `relativeLift` | (rate − control rate) / control rate | | `sequential.pValue`, `sequential.ci` | always-valid p-value and 95% interval of the absolute difference: what decisions use | | `relativeCi` | the always-valid interval as a relative lift | | `significant` | the always-valid p-value is below 0.05 after Holm correction across the comparisons | | `pValue`, `ci`, `zScore` | the fixed-horizon two-proportion z-test, for reference only | | `probabilityToBeat` | probability that the variant's true rate is above the control's (normal approximation) | Plus the sample-ratio-mismatch check: a chi-square test of the observed split against the configured weights, flagged when p < 0.001. When a targeting flow's conditions decide who reaches which variant, the expected split depends on who visits, so the check is skipped and `verdict.notes` says so. ## The verdict | `status` | When | What to do | |---|---|---| | `no_data` | no exposures yet | wait, or check the setup with `splithub doctor` | | `insufficient_sample` | fewer than 100 visitors in a variant or fewer than 50 conversions in total, or the sequential test has not decided yet | keep running; the message says about how many visitors per variant will settle it | | `srm_detected` | the split does not match the weights | the setup is broken (bots, caching, a variant that errors, a redirect loop); fix it, restart as a new experiment | | `winner_found` | a variant beats the control, or the control beats a significantly worse variant | read `winner` and `message`, then finish the experiment | | `no_significant_difference` | every interval rules out a change of 10% or more, or every variant reached the planned sample | ship the simpler variant, finish | | `guardrail_breached` | a variant is significantly worse on a guardrail goal | do not ship it; find out what broke | "No difference" must be earned like a winner: the always-valid interval of every comparison sits within ±10% of the control rate (`mde`, the relative effect worth detecting), or every variant passed the sample where the test has 80% power for that effect. `message` is one sentence written for a person or an agent to act on, for example: *Stop: "bold" beats "control" (+18.3%, always-valid p=0.0120). Ship "bold".* Agents are told in the skills to quote it verbatim and never to call a winner from raw rates. Next to it, `voice` is a friendlier line for people, and `daysToVerdict` estimates when to look again at the current traffic. When an experiment has run 14 days (or reached its planned sample) with nothing significant and a verdict is more than 30 days off or every lift is under 3%, or when the gap left is so small that proving it would take months, `voice.advice` is `move_on`: the experiment has run its course. It never changes `verdict.status`. `minDays` (opt-in, 0–30, default 0) holds a verdict until that many days of data are in; waiting messages mention days only when it is set. ## Sample quality and stars Both are informational: they never change the verdict. - `verdict.sample.quality` is `small` below the minimums, `good` once every variant has its share of `goodSample` (default 5,000 visitors in total, set per experiment with `goodSample` or `create --good-sample`), and `ok` in between. - `verdict.sample.stars` (0–5) counts five checks in `verdict.sample.checks`: the minimums are met; the split matches the weights with 1,000+ visitors; the good sample is reached; the sample is big enough to spot a 10% change; it has run a full week. With a broken split the score is capped at 1. ## Sample size Rule of thumb for a classic test at 95% confidence and 80% power: visitors per variant ≈ `16 · p(1−p) / (p·L)²` with baseline rate `p` and relative lift `L`. Baseline 5% and lift 20% → about 7,600 per variant; baseline 2% and lift 10% → about 78,000. Being safe to check any time costs sample: SplitHub's sequential test needs about twice that (about 16,000 and 170,000 per variant for the same two cases). Ask before you start: ```bash npx splithub plan --baseline 0.05 --mde 0.2 [--variants 3] [] ``` It answers visitors per variant for SplitHub's test, and with an experiment id the days at that experiment's traffic. No account needed; the same answer is `GET https://go.splithub.io/v1/plan?baseline=0.05&mde=0.2&variants=2`. ## Several variants With three or more variants every comparison is against the control, and significance is Holm-corrected across the comparisons (same guarantee as Bonferroni, more power). Pairwise comparisons between non-control variants are not reported; create a new experiment with the two finalists if you need that. ## Secondary metrics and guardrails Goals in `metrics` are read with the same sequential test as the primary goal: - **secondary**: two-sided, Holm-corrected across all secondary comparisons. Informational. - **guardrails**: one-sided (harm only, in the direction you set with `worse`), Holm-corrected across all guardrail comparisons, judged once both sides have the minimum 100 visitors. A variant significantly worse on a guardrail is never the winner: the verdict becomes `guardrail_breached` and `verdict.guardrailBreached[]` lists the metric, the variant and the lift. When the decided winner is the control or is clean, the call stands and the harm is added to `verdict.notes`. Each family is corrected on its own, so adding metrics never weakens the primary verdict. ## Multivariate experiments An `mvt` experiment gets the verdict over its combinations, plus `factors[]`: for each factor, every level's visitors and conversions summed over the combinations that use it and compared with the factor's first level. Factor levels and combinations are Holm-corrected together, so reading both keeps the 5% false-positive rate. `best` names the level that beats the first one significantly with the largest lift. ## Segments Breakdowns by country, device, browser, platform, app version, source, channel, landing page and more are for hypotheses, not verdicts: slicing after the fact finds noise. Each segment carries its own comparison and a `small` flag when it has too few visitors. A slice (`results --from --to --filter`) is marked as one and carries no advice; the decision stays with the whole experiment. If a segment looks interesting, run a targeted experiment on it. ## Holdout With a `holdout` in the config, `splithub results __holdout` compares the held-out visitors with everyone who was enrolled, by the same test: the long-term effect of all your experiments together. --- # React `@splithub/react` collects analytics and renders experiments in any React 18+ app (Vite, CRA, Remix, Astro islands). Next.js App Router has its own package, see [Next.js](/docs/nextjs); so do React Native and Expo, see [React Native and Expo](/docs/react-native). ```bash npm i @splithub/react @splithub/core ``` ## Provider ```tsx import { SplitHubProvider } from '@splithub/react'; import config from '../splithub.config'; createRoot(document.getElementById('root')!).render( ); ``` The provider creates the client synchronously, reads or sets the `sh_vid` cookie, applies cached live overrides (cloud mode) and starts the event queue. With `property` it also records pageviews and events for that [analytics property](#analytics). Props: `config`, `property?`, `visitorId?` (SSR: pass the id from the cookie so server and client agree), `overrides?`, `client?`, `pageviews?`, `trackNavigation?`, `context?`, `remote?`, `autoGoals?`, `devtools?`. ## Component experiments ```tsx import { Experiment, Variant } from '@splithub/react'; ``` One `` renders by assignment; the exposure is recorded on mount. Unknown or not-running experiments render the control. ## Value experiments ```tsx const title = useValue('hero-title'); const price = useValue('price-anchor'); const layout = useValue<{ columns: number; dense: boolean }>('grid'); ``` `useValue` returns the control's value when the experiment is not running, so the page never breaks when a test is paused. ## Hooks | Hook | Returns | |---|---| | `useVariant(id)` | the variant key (`'control'`, `'bold'`…); records an exposure the first time | | `useValue(id)` | the payload of a `value` experiment | | `useTrack()` | `(name, props?) => void`: an event, any name | | `useIdentify()` | `(userId, traits?) => void`: ties the visitor to your user; `null` on sign-out | | `usePageview(path)` | reports a page yourself, for when automatic navigation tracking is off | | `useSplitHub()` | the underlying `@splithub/core` client | `useVariant` is for the same experiment in several places: the visitor gets one variant everywhere. ## Goals ```tsx const track = useTrack(); return ( <> ); ``` Pageview goals fire for the current path on load and on every client-side navigation; there is nothing to add to your router. Click goals are bound automatically from the config's selectors. ## Analytics Name an [analytics property](/docs/analytics) on the provider and the same install collects pageviews and events. `npx splithub analytics connect` adds the prop for you. ```tsx ``` - **Pageviews** are automatic: one on load and one on every client-side navigation (`pushState`, `replaceState`, back and forward, hash routes), with time on page and scroll depth. To report pages yourself, pass `trackNavigation={false}` and call `usePageview(pathname)` in the layout. - **Events**: `useTrack()` is the one call. `track('Video Played', { id: 3 })` records the event in the property and converts every experiment whose goal it is, in one row. Names are letters, digits, spaces and `_ . : / -`, up to 64 characters; props are a flat object up to 4 KB. - **Users**: call `identify` after sign-in and with `null` on sign-out. ```tsx import { useEffect } from 'react'; import { useIdentify } from '@splithub/react'; export function IdentifyUser({ user }: { user: { id: string; plan: string } | null }) { const identify = useIdentify(); useEffect(() => { if (user) identify(user.id, { plan: user.plan }); else identify(null); }, [identify, user]); return null; } ``` Use your internal user id; keep emails and names out of traits and props. A development build (`NODE_ENV=development`) sends nothing to SplitHub Cloud: check tracking on a deploy or with `vite preview`. ## QA Append `?sh_force=hero-cta:bold` to any URL to see a variant, running or draft; no exposure is recorded. Keep the parameter in the URL while previewing; it is not saved across navigation. Several at once: `?sh_force=hero-cta:bold,hero-title:outcome`. To run drafts while you develop without forcing anything, set `runDraftsInDev: true` in the config (see [Drafts in development](/docs/config#drafts-in-development)). Add `?sh_debug=1`, or run `npx splithub dev`, to log every assignment with its reason and get a panel that switches variants. In Vite apps, `splithub()` from `@splithub/core/vite` prints what each experiment does in your dev terminal (see [Drafts in development](/docs/config#drafts-in-development)). ## SSR Any framework that renders on the server: read the `sh_vid` cookie, pass it as `visitorId` to the provider on both sides, set the cookie on the response when missing. `@splithub/core` exports `newVisitorId()` and `assign()` for server-side decisions. ## Rules - Never nest an `` inside a `` of the same experiment. - Hooks follow the rules of hooks: no conditional calls. - Keep the config import at the top of the app; the provider must be above every component that uses a hook. --- # Next.js `@splithub/next` covers the App Router (Next.js 14+): a middleware for the visitor cookie and redirect experiments, helpers for Server Components, and a client provider that agrees with the server on the first byte and collects analytics. `npx splithub init` creates the middleware and prints a provider snippet; add the layout integration below. ```bash npm i @splithub/next @splithub/react @splithub/core ``` ## Middleware ```ts // middleware.ts import { splithubMiddleware } from '@splithub/next'; import splitHubConfig from './splithub.config'; export default splithubMiddleware(splitHubConfig); export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] }; ``` It sets the `sh_vid` cookie, passes the id to Server Components in a header, rewrites the request for `redirect` experiments (the URL in the address bar does not change, the visitor sees no flash) and records edge exposures. In cloud mode it fetches live overrides at most once per 60 s per isolate; opt out with `splithubMiddleware(splitHubConfig, { remote: false })`. ## Layout ```tsx // app/layout.tsx (Server Component) import { SplitHubNextProvider } from '@splithub/next/client'; import { getSplitHub } from '@splithub/next/server'; import config from '../splithub.config'; export default async function RootLayout({ children }) { const sh = await getSplitHub(config); return ( {children} ); } ``` ## Analytics `property` on `SplitHubNextProvider` names the [analytics property](/docs/analytics) that receives pageviews and events; `npx splithub analytics connect` adds it to your layout. Leave it out and the provider runs experiments only. - **Pageviews**: one on the first load and one on every App Router navigation, with time on page and scroll depth. No `usePathname()` hook is needed. - **Events**: `useTrack()` from `@splithub/next/client` in a Client Component. One `track('Signup')` counts in the property and for every experiment whose goal it is. - **Users**: `useIdentify()` after sign-in, `identify(null)` on sign-out. ```tsx 'use client'; import { useTrack } from '@splithub/next/client'; export function PlayButton({ id }: { id: number }) { const track = useTrack(); return ; } ``` `next dev` sends nothing to SplitHub Cloud; check tracking on a deployed preview or with `next build && next start`. ## Client Components Same API as `@splithub/react`, imported from `@splithub/next/client`: `Experiment`, `Variant`, `useVariant`, `useValue`, `useTrack`, `useIdentify`, `usePageview`, plus `Exposure`. ## Server Components ```tsx import { getSplitHub } from '@splithub/next/server'; import { Exposure } from '@splithub/next/client'; import config from '../../splithub.config'; export default async function Page() { const sh = await getSplitHub(config); const title = sh.value('hero-title'); return ( <>

{title}

); } ``` `getSplitHub(config)` gives `.variant(id)` and `.value(id)` computed from the cookie the middleware set. `` records the exposure once on the client, so a server-rendered variant counts exactly like a client-rendered one. ## Self-hosted ingest route Install `@splithub/node` and `@splithub/adapter-sqlite` for this example. SQLite requires Node.js 22.13 or newer. ```ts // app/api/splithub/[[...path]]/route.ts import { createSplitHubHandler } from '@splithub/node'; import { sqliteStore } from '@splithub/adapter-sqlite'; import config from '../../../../splithub.config'; const handler = createSplitHubHandler({ config, store: sqliteStore(), adminToken: process.env.SPLITHUB_ADMIN_TOKEN }); export const POST = handler; export const GET = handler; export const runtime = 'nodejs'; ``` In cloud mode this file is not needed. ## Redirect experiments Declare `kind: 'redirect'` with `match` and a `to` per variant; no code. The middleware rewrites `/` to `/start` for the `new-landing` variant and records the exposure at the edge. Details and the drop-in alternative: [Redirect experiments](/docs/redirect-experiments). ## Why there is no flicker The middleware decides the visitor id before anything renders. Server Components compute the same deterministic assignment as the client, and the client provider receives that id and the same overrides as props. Both sides agree on the first byte, so hydration never swaps a variant. --- # Drop-in script For sites without a build step (static HTML, Webflow, Framer, Shopify themes, WordPress, landing-page builders) SplitHub Cloud serves a small script that collects analytics, runs redirect experiments and tracks goals from the hosted config. ```html ``` Put it in ``, as early as possible. The public key is safe to publish: it can only send events and read the public config. ## What it does on every page 1. Reads the hosted config and live overrides (cached in `localStorage`, refreshed on load when older than 60 s). 2. For `redirect` experiments whose `match` fits the current path: assigns the visitor with the same hash as the SDK (targeting flows included) and, when the variant points elsewhere, navigates to that destination. The rendering hold below reduces flashes. 3. Records the exposure, fires `pageview` goals, binds `click` goals from their selectors, and exposes `window.splithub.track()` and `window.splithub.identify()`. 4. With `data-property`: records a pageview for the [analytics property](/docs/analytics), and another on every client-side navigation. ```html ``` ## Analytics `data-property` names the property that receives pageviews and events; without it the script sends no analytics. Find the id on the property's page in the dashboard, or with `npx splithub analytics`. - **Pageviews**: one per page load and one per client-side navigation (history and hash routes), with time on page and scroll depth. Single-page sites need nothing extra. - **Events**: `splithub.track(name, props)`, any name of letters, digits, spaces and `_ . : / -` up to 64 characters, props a flat object up to 4 KB. The same call converts every experiment whose goal it is. - **Users**: `splithub.identify(userId, traits)` after sign-in, `splithub.identify(null)` on sign-out. Use your internal id, never an email. ```html ``` `window.splithub` exists as soon as the script tag runs, so inline handlers can call it before the config arrives; calls made before the script loads go through the `splithubq` queue. Keep one install per property: if the SDK also runs on the page for the same property, the first to send a page collects pageviews and the other sends only its own `track()` calls. ## Hold rendering during the decision On a page that is the subject of a redirect experiment, name it so the script holds rendering until the decision is made, even on the first visit with an empty cache: ```html ``` The script also holds rendering when there is no cached config or a cached redirect matches the page. The meta tag explicitly requests a decision for this page. The hold lasts up to 1,500 ms by default; a timeout can reveal the original page. Put the meta tag before the script. ## What it cannot do `component` and `value` experiments need the SDK in the app: the script does not change your markup. Use the script for analytics, whole-page tests and goal tracking, the SDK for everything inside the page. ## Options | Attribute | Default | Meaning | |---|---|---| | `data-key` | required | the project's public key | | `data-property` | none | the analytics property id (`sh-…`); without it no pageviews or events are sent | | `data-endpoint` | script origin + `/v1/events` | full events URL for a self-hosted cloud or staging | | `data-hold` | `1500` | maximum rendering hold in ms; `off` disables the hold | Use `?sh_debug=1` in the page URL for console diagnostics. ## Where experiments come from Create redirect experiments in the dashboard (New experiment → Redirect) or in `splithub.config.ts` and `push`. Targeting flows (country, device, UTM…) created in the dashboard are evaluated by the script with the visitor's geo from the edge. --- # Apps SplitHub runs in mobile and desktop apps the same way it runs on a website: one install counts screens and events, and runs experiments with the same verdicts. An app and a website can share a project, so a visitor who signs up on the web and buys in the app is one funnel. ## Pick your SDK | App | SDK | Install | |---|---|---| | React Native, Expo | `@splithub/react-native` | `npx splithub init` (detects Expo and React Native) | | iOS, macOS (Swift) | Swift package `SplitHub` | Xcode → Add Package Dependencies | | Android (Kotlin) | `io.splithub:splithub` (+ `splithub-compose`) | one Gradle line | Guides: [React Native and Expo](/docs/react-native), [iOS (Swift)](/docs/ios), [Android (Kotlin)](/docs/android). The native iOS and Android SDKs are new (version 0.1). They pass the same assignment tests as the web SDK; expect their API to grow. ## What stays the same - **Assignment** is the same local hash on every platform: no network call on render, and the same visitor id gets the same variant on web, iOS and Android. - **Events** are `track(name, props?)`. Any name works; mark one as a goal and experiments count it. - **Verdicts** come from the same sequential test, with the same split check, minimum sample and stars. See [Statistics](/docs/statistics). - **The CLI and the MCP server** read app experiments and app analytics with the same commands and tools. ## Screens are page views A screen is a `page` row: the url is the route (`/product/42` with Expo Router) or `/` (`/Settings`), the title is the screen name. So every page report, funnel step (`page:/Checkout`) and pageview goal (`{ type: 'pageview', path: '/Checkout' }`) works on screens with no change. React Native tracks screens for you with `` or ``. Native apps call `SplitHub.screen("Settings")`, or use `.splithubScreen("Settings")` in SwiftUI and `TrackScreen("Settings")` in Compose. ## Why app experiments live in the cloud A website deploys in minutes. An app waits for store review, then for people to update, and old versions stay in use for months. So for apps: - The variants ship inside the build: the code for each version you want to try. - Everything else lives in the dashboard: start, pause, finish, weights, `value` payloads, targeting. Installed apps pick it up on their next launch, with no release. - New `value` experiments (copy, prices, flags as JSON) can start from the dashboard, as long as the app reads that experiment. On the very first launch the SDK waits up to 400 ms for the experiments and shows the control meanwhile. After that it keeps a copy on the device, decides at once, and refreshes in the background. A change never flips a variant in the middle of a session. That is why native apps always use SplitHub Cloud, and why `init` suggests the cloud for React Native too. A React Native app can still keep `splithub.config.ts`; then `npx splithub push` keeps the cloud and the file in agreement, and pushed experiments reach apps whose build has their variants. Redirect experiments don't apply in an app: use `value` or `component`. ## Target app versions Every event carries the app's platform and version, so a targeting flow can use them: | Field | Values | Operators | |---|---|---| | `platform` | `ios`, `android`, `macos`, `windows`, `linux`, `web` | `is`, `is_not`, `in`, `not_in` | | `app_version` | `2.4.1` | `gte` (at least), `lt` (below), and the text operators | ```ts flow: { start: 'new-app', nodes: { 'new-app': { type: 'condition', conditions: [{ field: 'app_version', op: 'gte', value: '2.4' }], then: 'split', else: 'out' }, split: { type: 'split', outputs: [{ weight: 50, to: 'a' }, { weight: 50, to: 'b' }] }, a: { type: 'variant', variant: 'control' }, b: { type: 'variant', variant: 'new-checkout' }, out: { type: 'skip' }, }, }, ``` Versions compare part by part, so `2.10.0` is at least `2.9`. Gate on the first version that ships the new variant's code: older builds see the control and are not counted. More in [Targeting flows](/docs/targeting). ## Offline and sessions - Events go out in small batches, and right away when the app moves to the background. - Without a connection they wait on the device (up to 1,000) and go out later with their original time. Today's numbers can still grow a little when phones come back online. - A new session starts when the app returns after 30 minutes in the background. - Development builds send nothing, so your own taps never count. Check tracking with a release build. ## Reports for apps Make an **app property** for each app: `npx splithub analytics connect` creates one with `kind: 'app'` in an app project, or pick App when you create a property in the dashboard. It uses screens instead of pages and hides the web-only panels. - **Platform and app version** are dimensions and filters everywhere: `analytics --by app_version`, `results --by platform`, `--filter platform=ios`. - **Retention** is the report most apps live by: users grouped by the day or week they started, and how many came back after. `npx splithub analytics retention `; see [Reports](/docs/analytics-reports#retention). ## Allowed domains for apps An app has no web origin, so its app id stands in for one. Add it to an experiment's or property's allowed list next to your sites: ```bash npx splithub domains set property sh-x1h78w example.com com.acme.shop ``` App ids match exactly. A list that names only websites refuses apps. --- # React Native and Expo `@splithub/react-native` runs experiments and collects analytics in React Native and Expo apps. Variants are decided on the device before the first render, screens land in the same reports as your website, and events wait on the device while it is offline. Read [Apps](/docs/apps) first for what differs from the web. ## Install The quickest way, in the app folder: ```bash npx splithub init --mode cloud --yes ``` `init` detects Expo or React Native, installs the package and its storage module (through `npx expo install` on Expo), and wraps your entry in ``: `app/_layout.tsx` with Expo Router, else `App.tsx`. It also adds screen tracking, the agent skills and the MCP entry. When it cannot find the entry, it prints the snippet for you. By hand, Expo: ```bash npx expo install @splithub/react-native @react-native-async-storage/async-storage expo-application expo-device ``` Bare React Native (0.72 or newer): ```bash npm i @splithub/react-native @react-native-async-storage/async-storage cd ios && pod install ``` Storage keeps the visitor, their variants and unsent events between launches. The package uses `react-native-mmkv` when it is installed, else AsyncStorage. `expo-application` and `expo-device` report the app version and device model; without Expo, pass them yourself (below). ## Provider ```tsx import { SplitHubProvider, Experiment, Variant, useTrack } from '@splithub/react-native'; export default function App() { return ( ); } function Home() { const track = useTrack(); return ( <> Welcome to the shop. Shop in two taps.