Web analytics on your own Postgres

A typed SDK, one API for ingest and reads, and a database you run. No cookies for visitors, and raw IP addresses are never stored.

Start with the quick startor
0 visitors in the last five minutes
  • Visitors
    3new
  • Pageviews
    3new
  • Bounce rate
    100%new
  • Session
    0snew
Visitors per day
Top pages

These are this site's own numbers, read from the public API with @spoar/client. The same stats as JSON, or the project in the dashboard

Running on

Every public project on the production API, with its visitors over the last 30 days. Projects are public by default, so anyone can read these numbers.

Spoar in three steps

One project, two keys, one route. From an empty app to the first stored event.

Open the quick start
  1. Install the SDK

    Create the client in one file. The browser core has no framework dependency, so it runs in React, Next, Vue, Svelte, Astro or plain TypeScript, and the server entry runs anywhere with fetch.

    npm install @spoar/sdk
  2. Mount the proxy

    One route on your own domain adds the secret key and forwards the batch, so the browser never talks to a third party.

    One route
  3. Read your data

    Typed reads through @spoar/client, the dashboard, or one read-only SELECT with an API token.

    Client, API or SQL

What Spoar records, and how

Ingest pipeline

From the browser to a row in four hops

The SDK batches events and sends them with fetch, or sendBeacon when the page hides.

The API checks the key and origin, scores bots, enriches with geo, device and channel, and writes the row to Postgres.

A batch goes out at 20 events or every 5 seconds, with retries after 1, 4 and 16 seconds.
How it works
One event, four hopsPOST /v2/events
  1. sdkpageview /pricing
  2. sdkvisitor v_8f2c · session s_a81e
  3. proxy+ secret key · + forwarded ip
  4. apikey ok · origin ok · bot 0.02
  5. apiNL · desktop · organic
  6. apiip hashed with the daily salt, then dropped
  7. dbINSERT INTO events · 1 row
browser to rowstored in 38 ms

First-party proxy

Events travel on your domain

Filter lists block known tracker hosts and paths. A request to /_ra on your own origin matches none of them.

The proxy adds the secret key and the forwarded IP, refuses bodies over 60 KB, and passes nothing else along.

createProxy returns a fetch handler for Next, Hono, Elysia, Astro or a Worker.
Set up the proxy
yoursite.com/_raFirst-party request on your own originDelivered
tracker.io/collectThird-party host on a filter listBlocked
Same origin, no CNAME to uncloak

Privacy by default

Hashed today, gone tomorrow

The IP is hashed with a salt that rotates every UTC day, used for the rate limit and one bot signal, then dropped.

Visitors are a random id in localStorage and sessions a random id in sessionStorage. No cookies are set.

The only cookie on the whole system is the admin session.
Visitors and sessions
ip 203.0.113.42
hash(ip + daily salt)sha-256
9f2c41b7e0d3…a81e
rate limitone bot signalthen dropped

Web Vitals

Scored per route, credited to the right page

LCP, INP, CLS, TTFB and FCP are measured per route, so a single-page app credits each value to the page that produced it.

Each p75 is scored on a log-normal curve and combined into a Real Experience Score from 0 to 100.

The speedInsights plugin adds it to the client in one line.
Speed insights
–
Real Experience Score, needs 20 samples
Your visitEveryone, p75
  • LCP––
  • INP––
  • CLS––
  • FCP––
  • TTFB––

Your column is measured in this tab by web-vitals as you read; INP waits for a click or a key. The other column is what the speedInsights plugin stored for every visitor in the last 30 days.

Error tracking

Issues grouped across deploys

The errors plugin captures uncaught errors and rejections with their stack frames, query strings kept.

Fingerprints stay stable across deploys, and events scored as bots never open an issue.

The API captures its own unexpected errors with the same pipeline.
Errors plugin
TypeErrornot thrown yet

Cannot read properties of undefined (reading 'total')

  1. Thrown in this tab, caught on window error
  2. Captured by errors(), sent to POST /v2/events
  3. Grouped into an issue by type, message and frame
uncaught, on purpose

One file to set it up

Create the client, add the plugins you want, and call track with typed props.

SDK reference
lib/analytics.ts
ts
import { createAnalytics } from "@spoar/sdk";
import { errors, speedInsights } from "@spoar/sdk/plugins";

export const analytics = createAnalytics({
  project: "example.com",
  key: "pk_...",
  endpoint: "/_ra",
  plugins: [speedInsights(), errors()],
});

analytics.track("signup", { plan: "pro" });

Private by design, not by setting

  • No visitor cookies

    Visitors and sessions live in localStorage and sessionStorage. Nothing to put in a cookie banner.

  • No raw IP addresses

    Hashed with a salt that rotates every UTC day, then dropped. Visitor counts never use it.

  • Your database

    Events land in a Postgres you run, on Neon or your own server. Nothing is sent anywhere else.

  • Bot scoring

    User agent, headless and no-input signals combine into one score per event.

Why it exists

Remco Stoeten, who builds and runs it. v1 is still in the repository

Spoar started in February 2026 as a Hono ingestion service, a Next dashboard that read the same Postgres directly, and an SDK published as @remcostoeten/analytics. It ran on my own sites and still does.

That version had no read API: every chart was a query inside the dashboard, and the SDK and the server shared no types. So v2 is a rebuild on one contract of TypeBox schemas that the SDK, the API and the client all import, with one Elysia API for ingest, reads and sign-in.

Projects are public by default because I want my own numbers in the open. The dashboard at the top of this page is this site's, and the list below it is every project the production API serves.

Frequently asked questions

Still have a question? Open an issue

What is Spoar?

Web analytics you host yourself: a typed SDK for the browser and the server, one API for ingest, reads and sign-in, and a Postgres database you run.

Does it set cookies or need a consent banner?

It sets no cookies for visitors. A visitor is a random id in localStorage and a session a random id in sessionStorage. Whether you need consent depends on your jurisdiction; the SDK has consent and opt-out controls for when you do.

Do ad blockers stop it?

The SDK is bundled from npm, so there is no script to block. With the proxy, events go to /_ra on your own domain, a path filter lists have no rule for.

Which frameworks does it support?

Next.js, React, Vue, Svelte, Astro and plain TypeScript in the browser. The /server entry tracks from Node, Bun, Deno and Workers, and any language can post to the API over HTTP.

How do I keep my own visits out of the reports?

Events sent through the proxy while you are signed in to the dashboard are stored as internal and left out of reports. Opening a page with ?ra=ignore stops that browser from sending, and events from localhost and preview deployments are marked too.

Can I query the raw data?

Yes. The SQL route runs one read-only SELECT against the console views with an API token, from the dashboard, the query page on this site or your own code.

How do I host it?

Run the setup script against an empty Postgres database such as Neon to migrate, add your GitHub login and create the first project. Then deploy the API to any host that runs Bun 1.3 or later.