invisit
All posts

By Miraç Berk12 min read

How to proxy analytics through your own domain past ad blockers

What EasyPrivacy blocks, how to measure the traffic it hides, and tested analytics proxies for Next.js, Cloudflare Workers, nginx and Vercel.

A soft 3D browser window passing a small script parcel through a door on its own domain, lit by violet dithered light and soft shadows.

To serve analytics from your own domain, add two routes to your site: one that fetches the vendor’s script, and one that forwards events to the vendor’s collection endpoint along with each visitor’s IP address and user agent. Then point the tracker at those two paths. Blocklists such as EasyPrivacy match analytics mostly by the vendor’s domain, so a neutral path on your own domain is not caught by those rules.

A proxy changes where a request goes, not what you may collect. Global Privacy Control, Do Not Track and consent rules still apply.

Below: what the lists block (we ran 39 analytics URLs through them on November 3, 2026), how to measure your own gap, tested setups for Next.js, Cloudflare Workers, nginx and Vercel, and the pitfalls that quietly break counts.

Why do ad blockers block analytics scripts?

Most ad blockers block analytics because they subscribe to EasyPrivacy, the tracking list maintained in the EasyList repository. uBlock Origin enables it by default, next to EasyList and its own lists (uBlock Origin’s assets.json). The version we downloaded on November 3, 2026 (202611031204) runs to 57,235 lines.

EasyPrivacy matches analytics in two ways: by domain and by path. These rules are in that version:

EasyPrivacy, November 3, 2026
||plausible.io^$third-party
||cdn.usefathom.com^
||simpleanalyticscdn.com^$third-party
/_vercel/insights/script.js
/umami/script.js
/cdn-cgi/rum?

||plausible.io^ matches plausible.io and every subdomain, and $third-party limits it to pages on other sites. A rule that starts with / matches that path on any domain, including yours. That is why first-party is no guarantee: Vercel’s analytics script and Cloudflare’s /cdn-cgi/rum endpoint are served from your own domain and still blocked.

The list also catches individual proxies once someone reports them. easyprivacy_specific.txt contains ||ft.com/ingest? and ||mixpanel-proxy.ted.com^, rules written for two particular sites’ tracking routes.

Privacy-focused tools are not exempt. Plausible’s docs say “some blocklist maintainers block all analytics regardless of privacy practices”, and Fathom ended custom domains in March 2023 because they were “blocked by uBlock and a few others”.

Which analytics requests do ad blockers block?

Every analytics vendor domain we tested is blocked by EasyPrivacy, by uBlock Origin’s default lists and by AdGuard Tracking Protection, with one exception covered below. First-party paths are blocked only when a rule names them, and several vendors’ default paths are named.

Request from a page on www.example.com EasyPrivacy uBlock Origin defaults AdGuard Tracking Protection
plausible.io/js/pa-XXXXX.js Blocked Blocked Blocked
cdn.usefathom.com/script.js, cloud.umami.is/script.js Blocked Blocked Blocked
us-assets.i.posthog.com/static/array.js Blocked Blocked Blocked
static.cloudflareinsights.com/beacon.min.js Blocked Blocked Blocked
api.invisit.dev/invisit.js Passes Passes Passes
api.invisit.dev/v1/events, sent as a beacon Blocked Blocked Passes
Your domain: /_vercel/insights/script.js, /cdn-cgi/rum Blocked Blocked Blocked
Your domain: /matomo.js, /plausible.js Blocked Blocked Blocked
Your domain: /umami/script.js, /stats/api/event Blocked Blocked Passes
Your domain: /js/script.js, /api/event Passes Passes Passes
Your domain: /ingest/static/array.js, /ingest/e/ Passes Passes Passes
Your domain: /_i/invisit.js, /_i/v1/events Passes Passes Passes

We loaded the lists into Ghostery’s open-source adblocker engine (@ghostery/adblocker 2.20.3) and matched scripts as scripts and event endpoints as both fetch and beacon. “uBlock Origin defaults” means the nine lists it enables out of the box. Real extensions do more than an engine, such as CNAME uncloaking, covered under pitfalls.

invisit, the tool we build, is the exception: no rule names it yet, because it is new. One generic rule already hits every vendor, ours included: EasyPrivacy’s *$ping,third-party blocks any cross-site sendBeacon. invisit’s tracker sends a beacon when the page is hidden or closed, carrying engagement time, web vitals and anything still queued, so without a proxy those beacons are blocked. Through a first-party proxy they are same-site and pass.

EasyPrivacy’s header says it expires after 4 days, so treat the table as a snapshot.

How much traffic do ad blockers hide from analytics?

We found no independent, current measurement to cite, only vendor ranges. Plausible says sites without a proxy miss “typically between 5% and 25% depending on your audience” (Plausible, updated October 31, 2026). PostHog says a reverse proxy “typically increases event capture by 10-30%” (PostHog). Both come from vendors that sell proxying, and your number depends on your audience and their browsers.

How to measure your own analytics gap

Compare what your server served with what your analytics counted, for the same pages on the same day.

  1. Pick one full day and pages that load in full, such as landing pages; single-page app navigations never reach your server.
  2. Count human page loads on the server. From an nginx access log in the default combined format:
Terminal
awk -F'"' '$1 ~ /\[02\/Nov\/2026/ && $2 ~ /^GET \/[^ .]* HTTP/ && $3 ~ /^ 200 / && tolower($6) !~ /bot|crawl|spider|curl|python|headless/' /var/log/nginx/access.log | wc -l
  1. Take page views for the same day, in the same time zone, from your analytics.
  2. The share you are missing is 1 minus analytics page views divided by server page loads.
  3. After you switch to a proxy, repeat on the same weekday and compare.

The one-liner keeps successful GETs for paths without a file extension and drops self-declared bots. Bots posing as browsers inflate the server count, and pages served from a CDN cache never reach your origin, so use CDN logs if it caches HTML.

No, and it should not. A proxy changes which host receives a request. It does not change what the tracker collects or whom it is allowed to count.

Some visitors install a blocker precisely to avoid analytics. A proxy overrides a blocklist’s guess, not a visitor’s stated choice, so the signals above must keep working. The goal is an accurate count of what your site is allowed to count.

How does a first-party analytics proxy work?

A first-party analytics proxy is two routes on your domain:

  1. Script route. A GET that returns the vendor’s script. It can be cached.
  2. Event route. Usually a POST that forwards the body, the user agent, the visitor’s IP address and the Sec-GPC and DNT headers to the vendor, drops your cookies, and is never cached.

The examples below proxy Plausible, whose endpoint is strict about the visitor’s address and has a debug header to check it. Replace pa-XXXXX.js with your site’s script from Plausible’s settings, and replace /_hx with a name of your own: a path copied by many sites is exactly what earns a generic rule. Then load the tracker from your paths:

Plausible snippet
<script async src="/_hx/script.js"></script>
<script>
  ;((window.plausible =
    window.plausible ||
    function () {
      ;(plausible.q = plausible.q || []).push(arguments)
    }),
    (plausible.init =
      plausible.init ||
      function (i) {
        plausible.o = i || {}
      }))
  plausible.init({ endpoint: '/_hx/event' })
</script>

Next.js rewrites

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      { source: '/_hx/script.js', destination: 'https://plausible.io/js/pa-XXXXX.js' },
      { source: '/_hx/event', destination: 'https://plausible.io/api/event' },
    ]
  },
}

export default nextConfig

We ran this on Next.js 16.4.4 with next start. It forwarded the visitor’s cookies to the vendor, passed the vendor’s Set-Cookie back, and set X-Forwarded-For to the connecting address only when the request had none; an existing one went through unchanged. Behind a load balancer that overwrites the header, that is correct. On a server exposed directly, a visitor can write any address there. On Vercel, the platform sets it.

If your app has a proxy.ts or middleware.ts, exclude the proxy path from its matcher: PostHog’s Next.js guide warns that a catch-all matcher can swallow event routes while the script still loads. PostHog’s paths end in a slash, so that guide also sets skipTrailingSlashRedirect: true.

Cloudflare Worker

src/worker.ts
const SCRIPT = 'https://plausible.io/js/pa-XXXXX.js'
const EVENT = 'https://plausible.io/api/event'

export default {
  async fetch(request) {
    const { pathname } = new URL(request.url)
    if (pathname === '/_hx/script.js') {
      return fetch(SCRIPT, { cf: { cacheTtl: 3600 } })
    }
    if (pathname === '/_hx/event') {
      const upstream = new Request(EVENT, request)
      upstream.headers.delete('Cookie')
      upstream.headers.set('X-Forwarded-For', request.headers.get('CF-Connecting-IP') ?? '')
      return fetch(upstream)
    }
    return fetch(request)
  },
} satisfies ExportedHandler

Add a route such as *example.com/_hx/* so the Worker sees only those requests. We ran it locally with wrangler 4.157.0: the cookie was dropped, and the user agent, body, Sec-GPC and DNT arrived with the visitor’s address. The script is cached for an hour at Cloudflare’s edge.

nginx

nginx server block
location = /_hx/script.js {
    proxy_pass https://plausible.io/js/pa-XXXXX.js;
    proxy_set_header Host plausible.io;
    proxy_ssl_server_name on;
}

location = /_hx/event {
    proxy_pass https://plausible.io/api/event;
    proxy_set_header Host plausible.io;
    proxy_ssl_server_name on;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Cookie "";
}

We tested this on nginx 1.31.8 against Plausible itself with its X-Debug-Request: true header, which returns the IP address Plausible would count and records nothing. It returned the address nginx forwarded. Keep proxy_ssl_server_name on: it is off by default, and without it our proxy to Cloudflare-hosted cloud.umami.is failed with a 502.

$remote_addr is the visitor only when nginx faces the internet; behind a CDN or load balancer, restore it first with set_real_ip_from and real_ip_header. Plausible’s nginx guide uses $proxy_add_x_forwarded_for, which appends to whatever the visitor sent, while Plausible reads the first valid address in the list.

Vercel rewrites

vercel.json
{
  "rewrites": [
    { "source": "/_hx/script.js", "destination": "https://plausible.io/js/pa-XXXXX.js" },
    { "source": "/_hx/event", "destination": "https://plausible.io/api/event" }
  ]
}

This works for any framework deployed on Vercel. Vercel’s rewrites docs say external rewrites forward the visitor’s public IP in x-forwarded-for and x-real-ip, and that projects created on or after April 6, 2026 cache external responses according to the upstream Cache-Control. We validated and compiled this file with @vercel/routing-utils 6.6.1, the package Vercel uses for routes, but did not deploy it.

What does each analytics vendor document for proxying?

As of November 3, 2026, every tool below except Fathom documents a way to serve tracking from your domain. PostHog hosts one for free; Plausible, Matomo and Simple Analytics do on Enterprise.

Tool What it documents Source
Plausible Guides for Cloudflare, Vercel, Netlify and nginx, a WordPress toggle, a managed proxy on Enterprise Proxy guides
Umami Proxy the script and /api/send; self-hosters can rename both with TRACKER_SCRIPT_NAME and COLLECT_API_ENDPOINT Docs
PostHog A managed reverse proxy on your subdomain, free on PostHog Cloud and routed through Cloudflare, or your own Docs
Matomo tracker-proxy, a PHP script (PHP 7.2 or newer) you host; a custom domain on Cloud Enterprise tracker-proxy, pricing
Fathom Custom domains ended in March 2023; earlier ones keep working Changelog
Simple Analytics Proxy guides for NGINX, Caddy, Netlify and Vercel; a CNAME subdomain on Enterprise Docs, pricing
Google Analytics 4 Google tag gateway, through your CDN, load balancer or web server Docs
Vercel Web Analytics First-party on /_vercel/insights/*; package version 2 (March 11, 2026) discovers its endpoints instead of one predictable path Changelog
Cloudflare Web Analytics Automatic setup on proxied sites reports to your own /cdn-cgi/rum; Cloudflare says the beacon is blocked FAQ
invisit @invisitdev/server serves the script and every tracker endpoint under /_i Server SDK

Vercel’s troubleshooting page recommends against any reverse proxy in front of Vercel, because visitor identification hashes the incoming request.

How to serve invisit from your own domain

invisit’s proxy is part of its server SDK, @invisitdev/server, rather than a rewrite you write yourself. A plain rewrite to invisit’s collector works, but every visitor then arrives with your server’s address: visitors on the same browser and device type merge into one, and countries show where your server is. The collector accepts a forwarded visitor address only from a proxy that proves itself with your site’s ingest-scoped API key, so nobody else can spoof one.

Create the client with an API key that has only the ingest scope, stored as a server-side variable:

lib/invisit.ts
import { createInvisit } from '@invisitdev/server'

export const invisit = createInvisit({ key: process.env.INVISIT_API_KEY! })

In Next.js 16 it goes in proxy.ts (before 16, middleware.ts with export async function middleware):

proxy.ts
import { NextResponse, type NextFetchEvent, type NextRequest } from 'next/server'
import { invisit } from './lib/invisit'

export async function proxy(request: NextRequest, event: NextFetchEvent) {
  const response = await invisit.handle(request, { waitUntil: (p) => event.waitUntil(p) })
  return response ?? NextResponse.next()
}

export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }

Then load the tracker from your domain:

index.html
<script defer src="/_i/invisit.js" data-site="<site id>" data-collector="/_i"></script>

With the npm package, use init({ site: '<site id>', baseUrl: '/_i' }); the tracker then ships in your own bundle and only its collector calls use the proxy. To rename /_i, pass a prefix such as { prefix: '/_m' } to handle.

The proxy forwards only what the collector reads and keeps cookies, Authorization and Host on your side. We ran this proxy.ts on Next.js 16.4.4 against a stand-in collector: cookies and Set-Cookie were stripped, and the visitor’s address went out as a signed header. The same call records crawler reads, which no browser script sees. The server frameworks guide covers Hono, Express, SvelteKit, Astro, Workers and more.

Analytics proxy pitfalls

Most broken analytics proxies fail in one of four ways: the vendor gets the wrong IP address, the event route gets cached, the path is already on a blocklist, or cookies leak to the vendor.

Forwarding the visitor’s IP address and user agent

The vendor sees your proxy’s address unless you pass the visitor’s, and each vendor reads it differently.

Tool What its endpoint expects
Plausible CF-Connecting-IP if present, else the first valid address in X-Forwarded-For; a User-Agent is required (Events API)
Umami Its proxy example sets X-Forwarded-For and User-Agent (docs)
Matomo tracker-proxy sends the IP with a token_auth from a user with write or admin access (README)
Simple Analytics Collects no IP addresses; its Caddy guide removes X-Forwarded-For on purpose (docs)
invisit A signed address header that only @invisitdev/server sends (SDK)

Get this wrong with Plausible and events are dropped while the API still answers 202. Fathom’s bot detection docs warn that proxied visitors can look like bots.

Cloudflare adds its own trap: in a Worker request to another Cloudflare zone, CF-Connecting-IP is set to 2a06:98c0:3600::103 “for security reasons” (Cloudflare), so a vendor reading that header sees one address for everyone. On November 3, 2026, plausible.io answered from BunnyCDN; cloud.umami.is and api.invisit.dev answered from Cloudflare.

Caching the script but not the event endpoint

Cache the script, never the event route. Plausible’s script came back with Cache-Control: public, max-age=86400, must-revalidate and invisit’s with public, max-age=3600 plus an ETag; our Next.js, Worker and nginx tests passed the vendor’s header through. Make sure no cache-everything rule, or one that overrides origin headers, covers the event route, especially for trackers that send events as GET requests.

Path names that blocklists already know

Avoid the blocked paths in the table above, including vendor defaults: /_vercel/insights/, /cdn-cgi/rum, /umami/script.js, /matomo.js and /api/datafast/ are all on EasyPrivacy. Plausible and PostHog advise against words like analytics, tracking, telemetry and stats. Path rules are specific, so /analytics/script.js passed all three lists, but /analytics/collect? is a rule.

A subdomain that is a CNAME to the vendor is weaker than a path. uBlock Origin on Firefox looks up the canonical name and checks it against its lists, on by default. A subdomain pointing at simpleanalyticsexternal.com matches EasyPrivacy’s ||simpleanalyticsexternal.com^$third-party once uncloaked.

Cookies going the wrong way

Rewrites copy headers both ways. Our Next.js test sent the visitor’s session cookie to the vendor and returned the vendor’s Set-Cookie on your domain. Strip Cookie on the way out, as the Worker and nginx examples do; where config rewrites cannot change headers, proxy from middleware or a Worker instead.

How to check that your analytics proxy works

Load your site with a blocker on and confirm that both requests go to your own domain and succeed.

  1. Open your site in a browser with uBlock Origin on its default lists.
  2. Open DevTools, select the Network tab, reload, and filter by your proxy path.
  3. The script request should come from your domain with status 200, and the event request should return the vendor’s success status, such as 202.
  4. Clear the filter and check that no analytics request still goes to the vendor’s domain. Requests a blocker stopped fail with net::ERR_BLOCKED_BY_CLIENT in Chrome.
  5. Check the vendor’s realtime view: your own visit should show your real country, not your server’s.
  6. For Plausible, send one request with X-Debug-Request: true through the proxy and confirm the IP it returns is yours.

Before you ship the proxy