splithub.config.ts
Every field of the config: storage, goals, properties, experiments, variants, traffic, schedule, targeting.
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.
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:
// 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('<key>', props?), or track('<event>') 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
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) |
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.
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 |
Variants by kind
// 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)
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.
Layers and holdout
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,variantsor 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
idfor 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.