# 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.

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`:

```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

```ts
// 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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. $99 once. Lifetime access and updates.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
