anit.guru
Engineering

Building Privacy-First Analytics from Scratch

March 16, 2026·3 min read·AnITGuru
analyticsprivacynextjsneon
Share

Why We Built Our Own Analytics

Google Analytics is the default for most websites, but it comes with a cost: invasive tracking, cookie banners, and sharing your visitors' data with the world's largest advertising company. Simple Analytics and Plausible are great alternatives, but they cost money.

We wanted something different — a privacy-first analytics tool that's free, self-hostable, and doesn't compromise on the data you actually need.

The Architecture

Our analytics stack is surprisingly simple:

typescript
// The entire tracking script is ~1KB
(function () {
  if (navigator.doNotTrack === "1") return;

  var endpoint = serverUrl + "/api/analytics/collect";

  function send(payload, useBeacon) {
    var body = JSON.stringify(payload);
    if (useBeacon && navigator.sendBeacon) {
      navigator.sendBeacon(endpoint,
        new Blob([body], { type: "text/plain" }));
    } else {
      fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: body,
        keepalive: true,
      });
    }
  }
})();

No Cookies, No Fingerprinting

Instead of cookies, we use a daily-rotating hash of the visitor's IP + User Agent + date. This gives us unique visitor counting without storing any personally identifiable information:

typescript
const visitorId = createHash("sha256")
  .update(`${ip}|${userAgent}|${today}|${siteId}`)
  .digest("hex")
  .slice(0, 16);

The hash rotates daily, so we can't track anyone across days. No cookie banners needed.

The Database Layer

We use Neon PostgreSQL in production with a SQLite fallback for local development. The abstraction layer makes this seamless:

typescript
const DATABASE_URL = process.env.DATABASE_URL;
const useNeon = !!DATABASE_URL;

async function queryAll<T>(sql: string, params: unknown[]) {
  if (useNeon) {
    const fn = getSql();
    let idx = 0;
    const pgSql = sql.replace(/\?/g, () => `$${++idx}`);
    return await fn.query(pgSql, params);
  }
  const db = await getDb();
  return db.prepare(sql).all(...params);
}

What We Track

  • Pageviews and unique visitors (daily hash, not persistent)
  • Referrers (where traffic comes from)
  • Browsers, OS, and device types (parsed from User-Agent)
  • Countries (from Netlify's x-country header)
  • UTM parameters (campaign tracking)
  • Page duration (via visibilitychange events)
  • Bounce rate (calculated per-session, not per-pageview)

What We Don't Track

  • No cookies
  • No fingerprinting
  • No cross-site tracking
  • No personal data storage
  • No third-party scripts
  • Respects Do Not Track

The Dashboard

The dashboard is built with React and Recharts, showing real-time data with a date range picker. Admin users can manage sites, toggle public visibility for demo access, and export data as CSV.

Lessons Learned

  1. sendBeacon with application/json triggers CORS preflight — we use text/plain instead and parse JSON on the server regardless of Content-Type.
  2. SQLite's CURRENT_TIMESTAMP format differs from JavaScript's toISOString() — always normalize datetime comparisons.
  3. Bounce rate should be per-session, not per-pageview — a visitor who views 3 pages isn't bouncing even if each page has is_bounce=1 by default.

Try It

Add the tracking script to your site:

html
<script defer src="https://anit.guru/t.js"
  data-site="yourdomain.com"></script>

Then log in at anit.guru/tools/analytics to see your data. There's a demo account too — just click "Try the demo" on the login page.

Enjoyed this article? Share it.

Share
AnITGuru

AnITGuru

Creator

Writing about web development, privacy, and open-source tools at anit.guru.