smolanalytics
log inStart free
docs · setup

Add it to anything. One snippet, or one endpoint.

Every stack and every level of complexity, with exact copy-paste code: a website, React, Vue, Next.js, a multi-tenant SaaS, iOS and Android, any backend language, no-code builders, and self-host. Then connect your AI and ask your numbers in plain english.

You add smolanalytics to any stack in one of two ways, and both are tiny. For anything that runs in a browser (a plain website, React, Vue, Next.js, or an app built with Lovable/Bolt/v0/Replit) you drop one script tag, smolanalytics.init(key, { host }), and it autocaptures pageviews (including SPA route changes) and clicks; you add track() for the funnel moments and identify(userId) to tie a person's events together. For everything the browser never sees (mobile apps, servers, webhooks, cron jobs, payments) there is no SDK to install: you POST JSON to one endpoint, POST /v1/events, with an Authorization: Bearer WRITE_KEY header and a body of { name, distinct_id, properties }, or an array of up to 10,000 of them. Because client and server both key off the same distinct_id, a user's browser events and backend events join into one funnel automatically. iOS (Swift), Android (Kotlin), React Native, Node, Python, Go, Ruby, and PHP are all just that one HTTP call from the client they already use. Then you run smolanalytics connect once and ask your real numbers in plain English from Cursor or Claude Code, answered by your own model over MCP (47 tools, 13 prompts) with a CI test guaranteeing the editor's answer equals the dashboard's. Self-hosting the single MIT Go binary is free forever; the hosted cloud is a 14-day full trial with no card.
the whole idea

There is one endpoint. Everything else is a convenience on top.

Getting data in is deliberately small. In a browser, a script gives you autocapture and helpers. Everywhere else, you send events with one HTTP call, no dependency to add or keep updated:

the universal ingestion contract
POST https://YOUR_HOST/v1/events
Authorization: Bearer YOUR_WRITE_KEY
Content-Type: application/json

{ "name": "checkout", "distinct_id": "user_123", "properties": { "amount": 29, "plan": "pro" } }

That is the entire API for sending data. A single event or an array of up to 10,000. The write key is write-only, so it is safe in client code. Keep the distinct_id stable and identical across web and server, and a person's events join into one funnel on their own.

level 1 · zero build step

A website or web app

Paste this into your <head>. It captures pageviews and clicks immediately; add track() for the moments you want funnels on.

index.html
<script src="https://YOUR_HOST/sdk.js"></script>
<script>
  smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });

  // the moments that matter (optional, but this is what powers funnels)
  smolanalytics.identify("user_123");             // ties a person's events together
  smolanalytics.track("signup", { plan: "pro" });
  smolanalytics.track("activate");                // your core aha moment
</script>
level 1 · spa

React or Vue

Same script, loaded once from your entry file so it is bundled with the app. SPA route changes (React Router, TanStack, Vue Router) are captured automatically, no per-route wiring.

src/analytics.ts (React, Vite / CRA / any SPA)
export function initAnalytics() {
  if (document.getElementById("smol")) return;
  const s = document.createElement("script");
  s.id = "smol"; s.src = "https://YOUR_HOST/sdk.js";
  s.onload = () => (window as any).smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });
  document.head.appendChild(s);
}
// call initAnalytics() once in src/main.tsx, before render
Vue: same script in index.html, or Nuxt via nuxt.config.ts
export default defineNuxtConfig({
  app: { head: { script: [
    { src: "https://YOUR_HOST/sdk.js" },
    { children: `smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });` },
  ] } },
});
level 2 · ssr framework

Next.js (App Router or Pages)

Load it with next/script in your root layout. Pageviews and clicks are captured on every route.

app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
      <Script src="https://YOUR_HOST/sdk.js" strategy="afterInteractive" />
      <Script id="smol-init" strategy="afterInteractive">
        {`smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });`}
      </Script>
    </html>
  );
}

Server-side events (a Route Handler, a Server Action, a Stripe webhook) post straight to the endpoint with the same distinct_id you pass to identify():

app/api/checkout/route.ts
await fetch(`${process.env.SMOLANALYTICS_HOST}/v1/events`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.SMOLANALYTICS_KEY}` },
  body: JSON.stringify({ name: "checkout", distinct_id: userId, properties: { amount: 29 } }),
});
level 3 · production saas

A multi-tenant SaaS

A real SaaS emits events from two places, and smolanalytics is built for exactly that split. The browser sends product usage; your server sends the things the browser never sees (payments, provisioning, webhooks, cron). Three rules make it clean:

  • 1One identity everywhere. Call identify(userId) in the browser and send that same distinct_id from the server. Client and server events fuse into one funnel per user.
  • 2Sites are never the meter. Every event is stamped with its site by the SDK, so all your surfaces (marketing site, app, docs) live on one instance and one bill. You are billed on events, not on how many sites or how many users you have.
  • 3Hard isolation when you need it. Each project is its own isolated instance (its own server and data). Keep a big customer's data fully separate by giving them their own instance; Solo includes 2, Pro 5.
server: the revenue events the browser can't see
// Stripe webhook, billing cron, or provisioning job, any backend language.
await fetch(`${SMOL_HOST}/v1/events`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${SMOL_KEY}` },
  body: JSON.stringify({
    name: "subscription_started",
    distinct_id: user.id,                 // the SAME id you identify() in the app
    properties: { plan: "pro", mrr: 29, seats: 3 },
  }),
});

Now the funnel from a marketing pageview, to signup in the app, to a payment on your server is one connected path, and you ask it directly: "what's the signup to paid conversion, and how long does it take?"

level 3 · native

Mobile apps (iOS, Android, React Native)

No SDK. Send events with the HTTP client the app already has. Use a stable distinct_id per user (your user id, or an id you store in Keychain / SharedPreferences), and track screens as events to get screen-flow paths.

iOS (Swift)
func track(_ name: String, _ props: [String: Any] = [:], distinctId: String) {
    var req = URLRequest(url: URL(string: "\(host)/v1/events")!)
    req.httpMethod = "POST"
    req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.httpBody = try? JSONSerialization.data(withJSONObject: [
        "name": name, "distinct_id": distinctId, "properties": props,
    ])
    URLSession.shared.dataTask(with: req).resume()
}
// track("signup", ["plan": "pro"], distinctId: userId)
// track("screen", ["name": "Checkout"], distinctId: userId)   // screen-flow paths
Android (Kotlin / OkHttp)
fun track(name: String, props: Map<String, Any> = emptyMap(), distinctId: String) {
    val payload = JSONObject(mapOf("name" to name, "distinct_id" to distinctId, "properties" to JSONObject(props)))
    val body = payload.toString().toRequestBody("application/json".toMediaType())
    val req = Request.Builder().url("$host/v1/events")
        .addHeader("Authorization", "Bearer $key").post(body).build()
    client.newCall(req).enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {}
        override fun onResponse(call: Call, r: Response) { r.close() }
    })
}
React Native / Expo
export const track = (name: string, props = {}, distinctId: string) =>
  fetch(`${HOST}/v1/events`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
    body: JSON.stringify({ name, distinct_id: distinctId, properties: props }),
  });

Battery tip: queue events and POST them as one array (up to 10,000) on background or foreground instead of one request each. Flutter and any other toolkit work the same way, it is the one endpoint with that toolkit's HTTP client.

level 2 · server-side

Any backend language

The events the browser never sees (payments, webhooks, cron jobs, API usage) post from any language. Same distinct_id as the client so they join up.

Node
await fetch(`${process.env.SMOL_HOST}/v1/events`, {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.SMOL_KEY}` },
  body: JSON.stringify({ name: "checkout", distinct_id: userId, properties: { amount: 29 } }),
});
Python
import requests
requests.post(f"{HOST}/v1/events",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"name": "signup", "distinct_id": user_id, "properties": {"plan": "pro"}})
Go
body, _ := json.Marshal(map[string]any{"name": "checkout", "distinct_id": userID, "properties": map[string]any{"amount": 29}})
req, _ := http.NewRequest("POST", host+"/v1/events", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
Ruby
require "net/http"; require "json"
uri = URI("#{HOST}/v1/events")
Net::HTTP.post(uri, { name: "signup", distinct_id: user_id }.to_json,
  "Authorization" => "Bearer #{KEY}", "Content-Type" => "application/json")
PHP
$ch = curl_init("$host/v1/events");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer $key", "Content-Type: application/json"],
  CURLOPT_POSTFIELDS => json_encode(["name" => "checkout", "distinct_id" => $userId]),
]);
curl_exec($ch);
level 0 · no code at all

Built with an AI builder

If you built your app with Lovable, Bolt, v0, Replit, or Base44 and you do not write code, you never open an editor. Sign up, and we hand you one prompt with your write key already in it. Paste it into your builder's chat and its AI drops the snippet in the right place for that platform.

free forever · MIT

Self-host the binary

The whole product is one open-source Go binary. Run it yourself, unlimited everything, same features, no account. The hosted cloud exists only for the day you would rather not run a server.

docker (fastest)
docker run -p 8080:8080 ghcr.io/arjun0606/smolanalytics
# dashboard on http://localhost:8080, ingestion at /v1/events

Prefer to kick the tyres with real data first? The live demo is a populated instance, no install. Full self-host notes are in the GitHub README.

the point of all this

Connect your AI and just ask

Once events are flowing, run one command. It wires smolanalytics into every coding assistant you have, so your editor's own model answers from your real data, your model, so the AI part costs nothing.

one command, then restart your editor
smolanalytics connect        # wires up Cursor, Claude Code, VS Code, Windsurf, …

Then ask, in the same window you write code:

what's my activation rate on iOS vs Android?
what's the signup → checkout conversion, and how long does it take?
did activation improve since we shipped the new onboarding?

Your assistant admits it hallucinates your numbers. This one cannot: answers come from the same deterministic reports the dashboard renders (47 MCP tools, 13 prompts), and a CI test fails the build if the editor and the dashboard ever disagree. There is also a dashboard ask bar for plain-english questions about your data. Which surface is which is explained on how it works.

reference

The event contract

fieldrequiredwhat it is
nameyesThe event, e.g. signup, checkout. A $ prefix marks internal web events (pageviews); yours have no prefix.
distinct_idnoWho did it. Use one stable value across web and server so a person's events join. Omit for anonymous counts.
propertiesnoAny JSON object: plan, amount, source. What you break funnels and cohorts down by.
  • Auth: Authorization: Bearer YOUR_WRITE_KEY. Write-only, safe in client code.
  • Batch: POST an array of up to 10,000 events in one request (max 4MB). Over the cap returns a clean 413.
  • Endpoint: POST /v1/events on your instance host. That is the entire write API.
  • Full API + all 47 tools: see every feature and the API doc.

Common questions

Do I need to install an SDK or a package?
No. On the web it is one script tag, no npm install and no build step. Off the web there is no SDK at all: you POST JSON to POST /v1/events with the HTTP client your language already has. That is the whole integration surface, which is why iOS, Android, Go, Python, and a Stripe webhook all look nearly identical.
How do browser events and server events end up in the same funnel?
The same distinct_id. Call identify(userId) in the browser and send the same value as distinct_id from your backend, and a user's pageview, their signup on the client, and their payment webhook on the server all join into one person's timeline and one funnel. Pick a stable id (your user id, or a generated id you store in Keychain / SharedPreferences on mobile).
Which key do I use, and is it safe in client code?
Each project (isolated instance) has one write key. It is write-only: it can send events and nothing else, so shipping it in a web page, a mobile binary, or a public repo exposes no data and grants no read access. Reading your numbers happens through your authenticated dashboard or your own AI over MCP, never through the write key.
What are the limits on an event or a batch?
One request is up to 4MB and a batch array is up to 10,000 events; go over and you get a clean 413, nothing is silently dropped. An event needs a name; distinct_id and properties are optional. Batching is the recommended way to save battery and network on mobile: queue events and flush on background or foreground.
I built my app with AI and do not write code. Can I still use this?
Yes. You never open an editor or a terminal. Sign up, copy the one prompt we hand you (your write key already in it), paste it into your builder's chat, and its AI drops the snippet in the right place. There is a page with the exact prompt for Lovable, Bolt, v0, Replit, and Base44.
Is mobile a real integration or a hack?
It is the intended path: ingestion is deliberately one HTTP endpoint so a native app needs no dependency. Track screens as events (track("screen", { name: "Checkout" })) to get screen-flow paths, the mobile equivalent of pageviews, then ask "what's my activation rate on iOS vs Android?" from your editor.
Start the 14-day trial
no credit card · or self-host free