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
- Open the app with uBlock Origin enabled.
- DevTools → Network → filter
ingest. You should seePOST /ingest/e/?...returning 200 and no blocked requests. - PostHog → Activity: the event lands within seconds.
- Compare a week of
$pageviewbefore 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.