If you load CogniPrep and watch the Network tab, you will not see a request to us.i.posthog.com. You will see requests to cogniprep.app/ingest/..., which is the same thing wearing our domain name.
Here is the whole setup, and the parts of it that are easy to get subtly wrong.
Why bother
Ad blockers and tracker blocking lists work on hostnames. us.i.posthog.com is on those lists. So is the CDN the library itself is served from. A double digit percentage of your visitors, skewed heavily toward exactly the technical audience most likely to be reading this, never sends you a single event.
That is not a rounding error you can shrug at, because it is not random. Your conversion funnel is silently measuring the subset of users who do not block trackers, and you cannot tell how large the missing set is from inside the data.
Routing through your own origin makes the requests first party. cogniprep.app/ingest/e/ is not on a blocklist, because it cannot be: it is a path on a site, and blocking by path is not how those lists work.
The rewrites, and why the order is load bearing
async rewrites() {
return [
// PostHog reverse proxy - order matters!
{ source: '/ingest/static/:path*', destination: 'https://us-assets.i.posthog.com/static/:path*' },
{ source: '/ingest/decide', destination: 'https://us.i.posthog.com/decide' },
{ source: '/ingest/:path*', destination: 'https://us.i.posthog.com/:path*' },
];
}
Next evaluates rewrites top to bottom and stops at the first match, so a catch-all placed above a specific rule eats it.
The specific rule that must win is the first one, and it is the one people miss, because it is the only place where two different upstream hosts are involved. PostHog serves the library and the recorder bundles from us-assets.i.posthog.com and takes events at us.i.posthog.com. They are not the same origin. Put /ingest/:path* first and every asset request gets proxied to the events host, which answers with something that is not JavaScript, and the library never loads at all. Your analytics do not degrade, they vanish, and the only symptom is silence.
I will be honest about the middle rule: it is redundant. /ingest/decide would be routed identically by the catch-all underneath it, to the same host, with the same path. It is there as documentation of an endpoint that matters, and it is harmless because it points where the fallthrough would point anyway. Worth knowing it is not doing work, in case you are copying this block and wondering what it protects against.
The one thing I would change: us-assets and us are baked in. If this ever needs the EU region those are three edits in a file nobody opens, and the failure mode of getting one of them wrong is the silent one above.
The client side is two lines and one gotcha
posthog.init(key, {
api_host: '/ingest',
ui_host: host,
// ...
});
api_host: '/ingest' is the relative path, which is what makes every request same origin.
ui_host is the gotcha. Once api_host is a path on your own site, the library no longer knows where the PostHog app lives, so anything that builds a link into the PostHog UI builds a broken one. ui_host is the actual PostHog URL, used for links only, never for data. One line, and leaving it out produces a class of bug you find months later by clicking something.
The middleware matcher
This is the part that bites Next projects specifically. Our middleware matcher:
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|ingest|opengraph-image|...).*)',
],
};
ingest is in that exclusion list next to _next/static, and it belongs there for the same reason.
Without it, middleware runs on every single analytics request. Ours reads cookies, decides whether a route is protected and writes a country cookie, which is a small amount of work that is entirely pointless for a proxied event beacon, multiplied by every page view and every custom event of every session. On a platform that bills middleware invocations, that is a line item made of nothing.
PostHog's own docs mention this and it is still the most commonly missed step, because nothing breaks. It just costs.
What this honestly does and does not buy you
It is a reverse proxy, not a disguise, and the distinction matters both ethically and practically.
Data still goes to PostHog. The user's browser sends it to us and we forward it. Anybody who opens DevTools can see /ingest and work out what it is in about four seconds, and a determined blocker can still block the path. What changes is that the default list-based blocking no longer catches it.
It also moves cost onto your bill. Every event is now a request your platform serves and proxies, with the egress that implies. For us that is acceptable because event volume is small, and it is small on purpose, which is the other half of this file.
One thing that does genuinely improve: your Content Security Policy can keep connect-src 'self' instead of allowlisting analytics hostnames, because there are no cross-origin analytics requests left to allow.
Keeping the volume small so the proxy stays cheap
The same config makes several decisions that all point the same way, and they are the reason proxying is affordable.
autocapture: false,
capture_heatmaps: false,
Autocapture fired an event on every click in the app. That was the bulk of both the event volume and the network chatter, and in exchange it produced $autocapture rows keyed by DOM position that nobody ever built an insight on. Named events, defined in one file, are the ones anybody actually reads. The cost of turning it off is heatmaps, which is a real loss and a fair trade.
const SESSION_RECORDING_SAMPLE_RATE = 0.1;
const shouldRecord = Math.random() < SESSION_RECORDING_SAMPLE_RATE;
// ...
disable_session_recording: !shouldRecord,
This one is the single largest saving on the page, and the reason is not obvious. PostHog can sample session recordings server side, but server side sampling decides whether a recording is kept. The rrweb recorder is downloaded and running in the browser for everybody either way. Deciding on the client means 90% of sessions never fetch the recorder at all.
capture_performance: { web_vitals: true, network_timing: false },
capture_exceptions: false,
Web vitals stay because they are a handful of events per session sent after the page settles, and they are the honest measure of the latency users actually feel. Network timing records a payload per request. Exceptions go to Sentry, which has the stack traces and the release tracking, and having two systems capture the same failure means two sources of truth and twice the requests.
persistence: 'localStorage',
The library default is 'localStorage+cookie', which sets a cookie. Our cookie policy page tells users what we set. Making the library's default contradict a page you publish is a small thing that is nonetheless just untrue, and localStorage still recognises a returning visitor, so nothing analytically useful is given up.
And it is not in the initial bundle
void import('posthog-js').then(({ default: posthog }) => { /* init */ });
Behind a dynamic import(), scheduled at the first idle moment:
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(() => loadPostHog(), { timeout: 4000 });
return () => window.cancelIdleCallback?.(handle);
}
const timer = setTimeout(loadPostHog, 1500);
Nothing is lost by starting late. capture_pageview: 'history_change' records the initial view whenever init happens, and the thin wrapper that call sites import queues any event fired before the library arrives. The setTimeout branch is there because Safari still has no requestIdleCallback.
There is also no React context provider, deliberately. posthog-js/react exists for the usePostHog and feature flag hooks. This app uses neither, so importing it would be bundle weight for an API nobody calls.
And the .catch() is empty on purpose. Blocked, offline, chunk failed: analytics is optional, the queued events are simply never sent, and the page does not care.
See it
Open cogniprep.app with DevTools on the Network tab and filter for ingest. Click around for a few seconds.
Every request is to cogniprep.app. Look at the first one to arrive: it will be under /ingest/static/, which is the library itself coming from the assets host through the first rewrite. The ones after it are events going to the other host through the catch-all. Two upstreams, one path prefix, and nothing in the list showing a third-party domain.
Then filter for autocapture and find nothing, which is the volume decision above, visible as an absence.
cogniprep.app/cookies is the page the persistence: 'localStorage' line exists to stay true to. Open Application, Local Storage in DevTools and you will find a ph_phc_..._posthog key holding the distinct id. That is the state the library keeps, sitting in the storage the config chose rather than in the cookie the default would have written.
Top comments (0)