Skip to content

Ad blockers eat a third of your analytics: proxy ingestion through your own domain

Blockers match on hostnames, not behaviour. A first-party /ingest route handler forwards events to PostHog server-side and recovers most of the missing traffic.

PostHog4 min readships at docs/solutions/posthog/ad-blocker-reverse-proxy.md

Tags: posthog · analytics · ad-blockers · reverse-proxy · nextjs · route-handlers

Your site gets 10,000 visits a day according to your server logs, and PostHog reports 7,200. The gap is stable, so it is not a bug you can catch by watching. Worse, it is not random: the people running blockers skew technical, privacy conscious and (if you are selling developer tools) exactly the audience you are trying to measure. Your funnel top is systematically wrong in the direction that matters most.

Why it happens

Blocklists like EasyPrivacy match on hostnames, not on what a request does. us.i.posthog.com is on every major list, as are the equivalents for every other analytics vendor. uBlock Origin, Brave's shields, AdGuard, Pi-hole and a growing number of default browser settings drop the request before it leaves the machine. Nothing is logged, nothing throws, and the SDK cannot tell you.

Typical loss is 10-30% of consumer traffic in Europe and North America, higher for technical audiences, lower for mobile web.

The trick is that the blocker never sees the destination: it sees the request your page makes. If that request goes to yourdomain.com/ingest/e/, there is no third-party hostname to match, and the list has nothing to fire on. Your server then forwards it to PostHog, from a data centre, where no blocker exists.

The usual advice, and why this repo does it differently

PostHog's documentation suggests rewrites in next.config.ts:

// the common approach
async rewrites() {
  return [
    { source: "/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*" },
    { source: "/ingest/:path*", destination: "https://us.i.posthog.com/:path*" },
  ];
}

That works. It is also invisible: you cannot log it, cannot rate-limit it, and cannot see when it starts returning 401s because someone rotated a key into the wrong region. And in a repo where the framework config is owned by a template or a platform team, editing it may not be an option at all.

A route handler does the same job in code you can read, test and instrument.

The route handler

// src/app/ingest/[...path]/route.ts
import { type NextRequest, NextResponse } from "next/server";

export const dynamic = "force-dynamic";

const DEFAULT_HOST = "https://us.i.posthog.com";

// Static assets live on a separate host: us.i.posthog.com -> us-assets.i.posthog.com
function assetHost(apiHost: URL): string {
  const match = /^(us|eu)\.i\.posthog\.com$/.exec(apiHost.hostname);
  if (!match) return apiHost.origin; // self-hosted: one origin for everything
  return `${apiHost.protocol}//${match[1]}-assets.i.posthog.com`;
}

const STRIPPED = new Set([
  "host",
  "connection",
  "keep-alive",
  "transfer-encoding",
  "upgrade",
  "content-length",
]);

async function proxy(request: NextRequest, segments: string[]): Promise<Response> {
  const apiHost = new URL(process.env.NEXT_PUBLIC_POSTHOG_HOST ?? DEFAULT_HOST);
  const isAsset = segments[0] === "static";
  const base = isAsset ? assetHost(apiHost) : apiHost.origin;
  const target = `${base}/${segments.map(encodeURIComponent).join("/")}${request.nextUrl.search}`;

  const headers = new Headers();
  for (const [name, value] of request.headers) {
    if (!STRIPPED.has(name.toLowerCase())) headers.set(name, value);
  }

  // Without this every event is geolocated to a data centre.
  const clientIp = request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip");
  if (clientIp) headers.set("x-forwarded-for", clientIp);

  const body =
    request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();

  let upstream: Response;
  try {
    upstream = await fetch(target, {
      method: request.method,
      headers,
      body,
      signal: AbortSignal.timeout(10_000),
      cache: "no-store",
      redirect: "manual",
    });
  } catch (error) {
    console.warn("[ingest] upstream failed:", error);
    return new NextResponse(null, { status: 204 }); // losing an event beats a console error
  }

  const responseHeaders = new Headers(upstream.headers);
  responseHeaders.delete("content-encoding");
  responseHeaders.delete("content-length");
  responseHeaders.set(
    "cache-control",
    isAsset ? "public, max-age=86400, stale-while-revalidate=604800" : "no-store",
  );

  return new NextResponse(upstream.body, {
    status: upstream.status,
    headers: responseHeaders,
  });
}

export async function GET(request: NextRequest, context: RouteContext<"/ingest/[...path]">) {
  return proxy(request, (await context.params).path);
}

export async function POST(request: NextRequest, context: RouteContext<"/ingest/[...path]">) {
  return proxy(request, (await context.params).path);
}

Then point the SDK at your own path:

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
  api_host: "/ingest",
  ui_host: "https://us.posthog.com", // so in-app links still open PostHog
});

The four details that make or break it

Two upstream hosts, not one. Events go to us.i.posthog.com; the SDK bundle, the session recorder and the toolbar come from us-assets.i.posthog.com. Proxy only the first and the library itself fails to load, which looks exactly like the problem you were trying to fix.

Forward the client IP. Your server is the client as far as PostHog is concerned, so without x-forwarded-for every event is geolocated to us-east-1. On Vercel the incoming header already holds the real address.

Strip content-encoding on the way back. fetch decompresses the response body for you; passing the original header through tells the browser to decompress it a second time, and it fails.

Never 500. A blocked or failing analytics call must not produce a red error in a user's console or, worse, a retry storm. Answer 204 and log it server-side, where you can actually see it.

Region and self-hosting

NEXT_PUBLIC_POSTHOG_HOST decides everything. US cloud is https://us.i.posthog.com, EU cloud is https://eu.i.posthog.com, and a self-hosted instance serves both events and assets from one origin, which is why assetHost() falls back to the host itself. Mismatching the key's region and the host gives a 401 that the browser SDK swallows: the check in bun run verify exists to catch exactly that.

Verifying, and the honest caveat

  1. Open the app with uBlock Origin enabled.
  2. DevTools → Network → filter ingest. You should see POST /ingest/e/?... returning 200 and no blocked requests.
  3. PostHog → Activity: the event lands within seconds.
  4. Compare a week of $pageview before and after the change. A 10-25% step up is the traffic you were previously losing.

The caveat: this is not permanent immunity. Blocklists can and do add specific paths, and the more common /ingest becomes as a convention the more likely it is to be listed. If the gap reappears, rename the route segment (the SDK's api_host and the folder name are the only two places it appears). Cookieless blockers and privacy browsers that strip storage will still cost you some identified sessions no matter what you proxy: the goal is recovering most of the loss, not pretending it is zero.

One more thing worth saying out loud: proxying does not change what data you collect or what consent you need. It removes a hostname from a blocklist, not your obligations under GDPR. Keep the consent gate, keep the retention policy, and keep personal data out of event properties.