ἀκρίβεια — exactness EN · DE
Feature

Analytics Without Cookies, a Banner, or Losing Half the Data to Ad-Blockers

How I run PostHog on this site: cookieless, no consent banner, EU-hosted, and reverse-proxied through my own domain so ad-blockers barely touch it. The real Cloudflare Function and client config, both liftable.

You can have real product analytics without a single cookie, without a consent banner, and without ad-blockers eating a third of your data. The trick is one idea: serve the analytics from your own domain instead of the vendor’s. Everything else — cookieless mode, EU hosting, no banner — follows from that, and the whole thing runs on free tiers.

This is exactly how analytics works on this site. Below is the real Cloudflare Pages Function and the real client config, not a sketch. Both are short enough to lift.

Why the default stack quietly loses on a dev site

The reflex is Google Analytics plus a cookie banner. On an audience of developers, that combination loses twice before you read a single number:

  • Ad-blockers kill GA on roughly 30–50% of developer traffic. google-analytics.com and googletagmanager.com are on every blocklist. A GA dashboard for a dev site is a precise-looking chart drawn over half-missing data — the worst kind of wrong, because it looks right.
  • A consent banner loses another slice — every study I’ve seen puts banner abandonment somewhere in the tens of percent — and it’s cookie-based tracking that forces the banner in the first place. In Germany, GDPR plus the TDDDG make a cookie-consent prompt effectively mandatory, and EU authorities have repeatedly found vanilla GA’s US data transfers unlawful anyway.

So the default stack is half-blind and legally shaky on precisely the audience I have. The fix isn’t a different dashboard; it’s removing the two things that cause both problems — third-party requests and cookies.

The one idea: first-party reverse proxy

Ad-blockers work off hostname blocklists. When the analytics script and its events load from eu.i.posthog.com, they’re trivially blocked. When they load from akribia.dev/ph/... — my own domain, my own path — there’s no third-party hostname to match, and the requests sail through. Same data, served first-party.

On Cloudflare Pages this is a single Function. Any file under functions/ is deployed as an edge worker automatically — no config, no separate service. Here is the entire proxy, functions/ph/[[path]].js:

// First-party reverse proxy for PostHog EU.
// /ph/static/* → eu-assets.i.posthog.com  (the posthog-js bundle)
// /ph/*        → eu.i.posthog.com         (capture + decide API)
const API_HOST = 'eu.i.posthog.com';
const ASSET_HOST = 'eu-assets.i.posthog.com';

export async function onRequest({ request }) {
  const url = new URL(request.url);
  const pathname = url.pathname.replace(/^\/ph/, '') || '/';
  const host = pathname.startsWith('/static/') ? ASSET_HOST : API_HOST;

  const target = `https://${host}${pathname}${url.search}`;
  const headers = new Headers(request.headers);
  headers.set('host', host);
  headers.delete('cookie'); // cookieless by construction — never forward cookies

  return fetch(target, {
    method: request.method,
    headers,
    body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    redirect: 'follow',
  });
}

Three things are load-bearing. It splits two upstreams — the static bundle lives on eu-assets, the capture API on eu.i — so one /ph prefix fronts both. It hosts in the EU (Frankfurt), which is what lets the privacy page promise EU data without an asterisk. And it deletes the cookie header on every request, so even if something tried to set one, it never round-trips. First-party and cookieless, enforced at the edge.

At this site’s scale it’s free: Cloudflare Pages Functions include 100k requests/day, and PostHog’s EU free tier covers 1M events/month (both verified 2026-07-15). Analytics that costs nothing until it’s genuinely working is the right shape for a project that isn’t making money yet.

The client: cookieless by construction

The proxy is half the story; the other half is initialising the client so it never wants a cookie in the first place. This is the real init from Analytics.astro:

posthog.init(key, {
  api_host: '/ph',                 // everything routes through the first-party proxy
  ui_host: 'https://eu.posthog.com',
  persistence: 'memory',           // cookieless: nothing survives the tab
  person_profiles: 'never',        // anonymous layer only — no people, ever
  capture_pageview: true,
  capture_pageleave: true,
  autocapture: false,              // no blanket DOM capture; I instrument on purpose
  disable_session_recording: true, // replay is opt-in only, and there's no opt-in yet
  disable_surveys: true,
});

persistence: 'memory' is the line that makes “no cookies” true rather than aspirational: PostHog keeps its state in memory only, so nothing is written to cookies or localStorage and nothing survives the tab. person_profiles: 'never' keeps it on the anonymous layer — I get aggregate behaviour, never a person. autocapture: false is a deliberate cost: I lose free click-tracking, but I don’t ship a firehose of DOM events I’ll never look at, and I instrument the handful of things I actually care about by hand.

Two honest touches worth copying. The whole script is gated on an environment variable — no PUBLIC_POSTHOG_KEY, no analytics ships at all — so local dev and previews are silent by default and turning it on is one deliberate setting. And screenshot runs are skipped:

if (/[?&]static/.test(location.search)) return; // OG-capture runs stay untracked

The site generates its social-share images by loading each page with ?static=1; without that guard, every OG render would fire a fake pageview. Small thing, but it’s the difference between numbers you trust and numbers you half-trust.

Instrument what you’ll act on, nothing more

With autocapture off, every event is a decision. The full list on this site is short on purpose: $pageview and $pageleave (which give time-on-page and exit pages for free), plus four hand-placed events — outbound_click, cta_click (any element tagged data-cta), coin_flip, and on posts a scroll-depth article_scroll (25/50/75/100%) with an article_read that only fires past 75% scroll and 45 seconds of dwell, so a bounce can’t masquerade as a read.

That last one is the whole philosophy in miniature: a “read” should mean someone read it. The rule I hold the dashboard to is a metric nobody acts on gets deleted — if I’m not going to change what I write or build based on a number, measuring it is just noise wearing a chart.

What this costs you — because it isn’t free of trade-offs

Cookieless-first-party is the right default for a site like this, but it is a set of trade-offs, not a free lunch:

  • No stitched journeys. With memory-only persistence and no cookie, each page load is its own anonymous event. I get page-level aggregates — which pages get read, roughly where people arrive from — not one visitor’s path stitched across five pages or across two visits. For funnels that must survive a page load (signup, checkout) the plan is server-side events from the payment webhook, which are ad-block-proof by construction anyway. Client-side, I deliberately can’t see returning visitors.
  • You now own a proxy path. If PostHog ever changes its asset host or API layout, my /ph route is mine to fix. It’s ten lines, but it’s ten lines I maintain.
  • “Barely blocked” is not “never blocked.” First-party serving defeats hostname blocklists, which is the overwhelming majority of blocking. A blocker matching on the script’s filename or payload shape can still catch some — this cuts the loss to a sliver, it doesn’t zero it.
  • The no-banner posture is a judgment, not a certainty. Cookieless, first-party, EU-hosted analytics is the same legal footing Plausible and Fathom operate under, and it’s a defensible reading of GDPR/TDDDG — but it is a reading. I’m an engineer, not your lawyer; the privacy page states exactly what’s collected so the claim stands on the implementation, and a real review happens before anything commercial launches.

Verdict

  • Worked: first-party serving plus cookieless init gives real, EU-hosted, banner-free analytics that ad-blockers barely dent — on a dev audience where GA would silently drop 30–50%. It’s ~10 lines of edge Function and ~10 lines of client config, free at 100k Function requests/day and 1M events/month, and turning it on is one environment variable.
  • Cost: no cross-page or cross-visit journeys client-side (page-level aggregates only); autocapture off means I hand-instrument every event; and I own the proxy path if the vendor moves things.
  • Blocked: anything that genuinely needs a durable identity — returning-visitor funnels, revenue attribution — can’t live in this anonymous client layer. That belongs in server-side events from the payment webhook, which is a different post and not built yet. And “no consent banner” rests on a legal reading I’ll have reviewed before selling anything, not on my say-so.

This site is static Astro on Cloudflare Pages; the analytics proxy runs natively as a Pages Function beside it. The build discipline behind the rest of it is in the workflow write-up.

← All build logs