Self-hosted (your database)
The ingest handler, the Store interface, Postgres/MySQL/SQLite/D1, the events table, attribution SQL, raw analytics counts.
In self mode the app hosts the ingest route and the store; exposures, goals, pageviews and events stay in your database and nothing leaves your infrastructure. The SDK, the CLI and the MCP server are the same as in the cloud; reads are raw counts, while verdicts and analytics reports come with SplitHub Cloud. It is free and has no limits.
// splithub.config.ts
storage: { mode: 'self', endpoint: '/api/splithub' }
The handler
@splithub/node exports one fetch-style handler that works in Next.js route handlers, Remix, Hono, Bun, Deno and plain Node (toNodeListener for Express-style servers).
import { createSplitHubHandler, toNodeListener } from '@splithub/node';
import { sqliteStore } from '@splithub/adapter-sqlite';
import config from './splithub.config';
const handler = createSplitHubHandler({
config,
store: sqliteStore({ file: './splithub.db' }),
adminToken: process.env.SPLITHUB_ADMIN_TOKEN, // protects status and results; health remains public
});
// Next.js app router: export const POST = handler; export const GET = handler;
// Express / Vite / http: app.use('/api/splithub', toNodeListener(handler));
Routes relative to the mount path:
| Method | Path | Purpose |
|---|---|---|
| POST | / |
ingest a batch from the SDK |
| GET | /status |
every experiment with exposures, visitors and days |
| GET | /results/:id |
visitors, conversions, rate and revenue per variant |
| GET | /properties |
every analytics property with raw counts (?days=30, or ?from=&to=) |
| GET | /properties/:id |
one property's raw counts |
| GET | /properties/:id/events |
its latest raw pageviews and events (?before=&limit=) |
| GET | /health |
liveness |
The handler accepts up to 500 events per batch and 4,096 characters of serialized props. Set adminToken in production; without it, every read above is public.
The CLI reads status and results from the local dev server (localhost:3000 for Next.js, 5173 for Vite) or from --url https://your.app/api/splithub, with --token / SPLITHUB_ADMIN_TOKEN.
Stores
| Store | Package | Notes |
|---|---|---|
| SQLite | @splithub/adapter-sqlite |
node:sqlite, Node 22.13+, zero setup; fine for one server |
| Cloudflare D1 | @splithub/adapter-d1 |
for Workers; d1Schema() gives the DDL |
| Postgres | generated | npx splithub init --mode self --db postgres writes splithub/store.ts using pg |
| MySQL | generated | --db mysql, uses mysql2 |
| anything else | --db custom |
a documented stub with the three methods to fill in (Prisma, Drizzle, Supabase, Mongo…) |
The Store interface
interface Store {
insertEvents(events: StoredEvent[]): Promise<number>; // skip existing ids, return inserted count
variantCounts(experiment: string, goal: string): Promise<VariantCounts[]>; // per variant: exposures, conversions, revenue
summaries(): Promise<ExperimentSummary[]>; // per experiment: exposures, visitors, first/last ts
propertyCounts?(property: string, since: number, until?: number): Promise<PropertyCounts>; // analytics: raw counts
propertyEvents?(property: string, options: { before: number; limit: number }): Promise<StoredEvent[]>; // newest rows first
close?(): Promise<void> | void;
}
Attribution rule, keep it exactly so self-hosted results equal the cloud's: a visitor converts for a variant when they sent the goal at or after their first exposure to that variant; exposures and conversions count unique visitors. createMemoryStore() is the reference implementation.
The events table
One table, one row per event. Column names are yours; the generated stores use these:
| column | type | meaning |
|---|---|---|
id |
text, primary key | client-generated, unique; dedupe on it |
type |
text | exposure, goal, page, event, engage or identify |
experiment, variant |
text | for exposures; on analytics rows experiment holds the property id |
goal |
text | for goals; on event rows the event name |
visitor_id, session_id |
text | the sh_vid cookie / the session |
ts |
bigint | client time, unix ms |
url |
text | page |
props |
json | goal or event payload, e.g. { "revenue": 49 }; a page's title and referrer; identify traits |
received_at |
bigint | server time, unix ms |
host, country, device, browser |
text | optional request metadata for breakdowns |
platform, app_version |
text | web for websites, else the app's platform (ios, android…) and version |
Indexes: (type, experiment, visitor_id, ts) and (type, goal, visitor_id, ts).
Attribution in SQL
WITH first_exposure AS (
SELECT visitor_id, variant, MIN(ts) AS first_ts
FROM splithub_events WHERE type = 'exposure' AND experiment = $1
GROUP BY visitor_id, variant
)
SELECT fe.variant,
COUNT(DISTINCT fe.visitor_id) AS exposures,
COUNT(DISTINCT g.visitor_id) AS conversions,
COALESCE(SUM((g.props->>'revenue')::numeric), 0) AS revenue
FROM first_exposure fe
LEFT JOIN splithub_events g
ON g.type = 'goal' AND g.goal = $2 AND g.visitor_id = fe.visitor_id AND g.ts >= fe.first_ts
GROUP BY fe.variant;
Prisma or Drizzle: map the table in your schema and run this one raw query for variantCounts. Supabase: the same SQL as a Postgres function called through rpc. Mongo: $group by variant and visitor with $min ts, then $lookup goals.
Analytics
Self-hosted projects keep their analytics properties in the config file. npx splithub analytics create --name "Marketing site" writes one with a generated id:
// splithub.config.ts
properties: {
'sh-x1h78w': { name: 'Marketing site' },
},
Name it on the provider (property="sh-x1h78w") and the handler stores its page, event, engage and identify rows in the same table. Rows of a property that is not in the config or not running are dropped. Each event and page row also converts the experiments whose goal is that event or page, so one track() call serves both.
Reads are raw counts for a range of UTC days: pageviews, visitors, visits, identified users and events by name.
GET /api/splithub/properties?days=7
GET /api/splithub/properties/sh-x1h78w?from=2026-10-01&to=2026-10-31
npx splithub analytics sh-x1h78w --days 7 prints the same. The SQLite, D1 and generated stores implement propertyCounts; a custom store without it answers 501 on a property's counts, and propertyCountsSql() from @splithub/node gives the queries for the SplitHub table layout. Reports with pages, sources, channels, visits, conversions, filters, funnels, paths and retention come with SplitHub Cloud.
Reports into your warehouse (cloud mode)
If you use the cloud but want data in your own database: CSV export per experiment and per property (summary, daily timeline, raw events), a webhook channel for alerts and the digest, or a cron that calls GET /v1/status, GET /v1/results/<id> and GET /v1/properties/<id>/summary with the API key and stores the answers. Raw events are a short tail in the cloud (7 days on Free, 30 with packs or Pro) while daily numbers stay much longer: pull summaries, not raw rows, for long-term reporting.
Do not
- Put database credentials in
splithub.config.ts; it ships to the browser. They belong in the route's environment. - Compute winners from raw rates in SQL: raw rates ignore sample size and peeking. Verdicts come with SplitHub Cloud.
- Read analytics counts as an experiment result: use
npx splithub resultsfor experiments.