Zero-Server Analytics: How I Replaced a SaaS Bill with Netlify Functions and GitHub

netlify analytics github
Zero-server analytics pipeline using Netlify Functions and GitHub commits

Analytics vendors want twenty to fifty dollars a month, a tracking pixel, and a cookie banner. For a content site that only needs to know what gets read and where traffic comes from, that is a bad trade. You pay forever, you add third party scripts, and you still own nothing when you cancel.

I wanted the opposite: private data, no recurring bill, JSON I could hand to a client tomorrow, and a pipeline that runs while I sleep. I am one person. If it needs babysitting, it does not ship.

So I built this. Netlify Functions collect events. Blob Store queues them. A scheduled function rolls them into daily snapshots and commits the results to GitHub. Monthly cost: zero. Maintenance: also zero. If you read How My Automated Analytics Reports Work, you know the reporting story. This is the plumbing underneath.

The take: for a solo founder or small team running a content site, skip the analytics SaaS. Netlify Functions plus Blob Store plus GitHub beats a dashboard subscription on every axis that matters to you.

What I was optimizing for

Four constraints, stated plainly:

  1. Private. No third party cookies. No vendor reading my traffic.
  2. Cheap. No monthly line item for charts I open once a week.
  3. Portable. Data in an open format I control, not locked in someone else's export flow.
  4. Automatic. No cron job I have to remember, no laptop that has to stay on.

That last one is the killer for small teams. Fancy self hosted stacks still need a human. This one does not.

How the pipeline runs

The shape is simple on purpose:

  1. A lightweight client script (static/analytics.js) sends page view events with privacy friendly payloads.
  2. A Netlify Function (collect-analytics.js) validates each payload and writes it to Netlify Blob Store.
  3. A scheduled Netlify Function (rollup-analytics.js) runs daily at 5 AM, drains the previous day's blobs, builds a summary, commits JSON to GitHub, and deletes processed events.
  4. Manual runs (npm run rollup:trigger -- --date YYYY-MM-DD) hit the same function with a token when I want a snapshot on demand.

No database to provision. No analytics SaaS. No dashboard to maintain. Serverless functions and a Git repo. That is the whole stack.

Collecting events without bloating the page

The client grabs page URL, referrer, UTM params, a hashed visitor ID, and performance metrics, then posts to the collect function. It uses sendBeacon when available so navigation never blocks on analytics:

async function sendAnalytics(payload) {
          try {
            const body = JSON.stringify(payload);
            if (navigator.sendBeacon) {
              const blob = new Blob([body], { type: 'application/json' });
              if (navigator.sendBeacon(ANALYTICS_ENDPOINT, blob)) return;
            }
            await fetch(ANALYTICS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
          } catch (err) {
            console.warn('analytics send failed', err);
          }
        }
        

The script lives in static/, so the build copies it straight to dist/static/. What I debug locally is what runs in production. No bundler surprises. If you use HTMX for navigation (see the architecture piece), add an htmx:afterSwap listener for virtual page views on client side transitions.

The collect function: validate, salt, stash

Under two hundred lines. Rejects oversized payloads and missing fields. Salts the visitor hash with ANALYTICS_SALT so nobody reverse engineers identities from the hash alone. Stores each event as JSON in Blob Store:

const store = getStore('analytics-events');
        await store.set(`queue/${date}/${crypto.randomUUID()}.json`, entry);
        

Append only semantics without provisioning Postgres. Events organized by date so the daily rollup is a folder read, not a query puzzle. Small teams should pick the dumbest storage model that works and move on to revenue work.

The rollup: where blobs become history

The rollup handler knows whether Netlify triggered it on schedule (x-netlify-event: schedule) or I called it manually with a shared secret. Scheduled runs default to yesterday. Manual runs accept a date override.

export const handler = schedule('0 5 * * *', rollupHandler);
        

Inside the handler:

  1. Load every blob under queue/{targetDate}/.
  2. Build summary stats: top paths, referrers, device mix, performance percentiles.
  3. Serialize and commit to GitHub via the REST API.
  4. Delete processed blobs.

Manual spot checks never overwrite scheduled reports. File names carry the run type:

const suffix = runType === 'manual' ? '-manual' : '-scheduled';
        const path = `analytics/${year}/${month}/${day}/analytics${suffix}.json`;
        

Why GitHub is the datastore

People blink at this. Then they get it.

Auditability. Every snapshot is version controlled. I diff traffic between days the same way I diff code.

Portability. JSON in a Git repo. Switch hosts, hand data to a client, feed a new tool. Nothing to export because the export already happened.

Cost. GitHub storage is effectively free at this scale. Blob Store handles retention once the rollup clears processed events.

Operational simplicity. The site already deploys from GitHub. Analytics live beside the codebase. One fewer system in my head.

For the broader hosting story, see How This Blog Works.

Safety rails

Proving it works

npm run rollup:trigger -- --date 2025-12-05 hits production with the token and optional date. Same code path as the scheduled run. Perfect after a deploy when you need to confirm env vars landed.

{
          "meta": {
            "date": "2025-12-05",
            "runType": "manual",
            "events": 63,
            "uniqueVisitors": 41
          },
          "traffic": { ... },
          "performance": { ... },
          "events": [ ... ]
        }
        

Every snapshot includes the raw events array for backfill, audit, or replay.

What I would tell a founder in the same spot

Start with collect, not dashboards. Wire the client script and collect function first. If events land in Blob Store, you are winning.

Commit to GitHub early. Versioned JSON beats a pretty chart you cannot diff.

Run the manual trigger once after deploy. One command proves the whole chain. Scheduled runs are just the same path on a timer.

Do not overbuild alerting on day one. Telegram hooks exist in my codebase for traffic dips and LCP spikes. They can wait until you know what normal looks like.

Reuse the pattern for client sites. The components are already parameterized. Same pipeline, different GitHub target.

Lessons

Most content sites do not need a fifty dollar analytics product. They need to know what pages work, where readers come from, and whether performance regressed. This pipeline delivers that for free, unattended, in a format you will never migrate out of.

The incentive stack favors SaaS analytics because recurring revenue is good for vendors, not because your blog needs Mixpanel. Build the boring pipe once. Then go write, sell, or ship the thing that actually grows the business.

Work with Kleto

I am James Cowan, founder of Kleto. We wire zero server analytics pipelines on Netlify so founders own their traffic data without another SaaS bill. Contact Kleto if that matches your stack.

Recommended

Why I Built My Own Analytics Pipeline (And What It Actually Costs) @jameslcowan analytics, javascript
Replacing the SaaS bill: how I built my own analytics, uptime, and BI stack with agentic development @jameslcowan analytics, observability
Fixing Navigation and Analytics: When Your Data Lies About User Behavior @jameslcowan htmx, javascript

Recommended

Why I Built My Own Analytics Pipeline (And What It Actually Costs) @jameslcowan analytics, javascript
Replacing the SaaS bill: how I built my own analytics, uptime, and BI stack with agentic development @jameslcowan analytics, observability
Fixing Navigation and Analytics: When Your Data Lies About User Behavior @jameslcowan htmx, javascript

Search by title, tag, description, or the prose itself. Results appear as you type.