Here is a two line experiment you can run right now. Both of these are reverse proxies in front of PostHog, on two sites built by the same person, in the same framework version, in the same week:
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -X POST https://cogniprep.app/ingest/e/
308 https://cogniprep.app/ingest/e
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -X POST https://nakodo.app/alcy/e/
400
The 400 is PostHog rejecting an empty event body, which is what success looks like here: the request reached PostHog. The 308 is Next.js redirecting, before the request has gone anywhere, because the URL ends in a slash.
Where the slash comes from
Analytics gets proxied through your own domain so that blocklists, which match hostnames, have nothing to match. On Nakodo that path is /alcy, after the bird in our illustrations, because /analytics and /ingest are themselves on the lists by now.
async rewrites() {
return [
{ source: `${POSTHOG_PATH}/static/:path*`, destination: `${POSTHOG_HOSTS.assets}/static/:path*` },
{ source: `${POSTHOG_PATH}/array/:path*`, destination: `${POSTHOG_HOSTS.assets}/array/:path*` },
{ source: `${POSTHOG_PATH}/:path*`, destination: `${POSTHOG_HOSTS.api}/:path*` },
];
}
The part nobody plans for is that PostHog's endpoints have a trailing slash in them. Events go to /e/, recorder chunks to /s/, the flags call to /decide/. Through the proxy that is /alcy/e/, and Next's default is trailingSlash: false, which does not mean "ignore trailing slashes". It means "redirect them away".
So every single analytics request from the browser becomes two requests: a 308 and then the real one. Twice the round trips, on the request that fires while the page is being unloaded, which is the single worst moment in a page's life to ask the browser to do something twice. A 308 does preserve the method and the body, so this is not guaranteed data loss, and I want to be precise about that rather than scaring you. It is an extra hop on every event, taken at the exact moment the browser is least interested in finishing your work, and there is no upside to it at all.
Turning it off is the easy half
skipTrailingSlashRedirect: true,
That is one line and it fixes the analytics path. It also fixes it for /pricing/, /guides/, and every other URL on the site, which is not a fix, because now two URLs serve the same page and crawlers have to pick one.
Writing the redirect back by hand
async redirects() {
return [{ source: `/:path((?!${POSTHOG_PATH.slice(1)}/).+)/`, destination: "/:path", permanent: true }];
}
POSTHOG_PATH is "/alcy", so slice(1) makes the pattern read /:path((?!alcy/).+)/. The constant is imported into next.config.ts from the same file the client reads, because a proxy path that is right in one of those two files and wrong in the other fails silently, which is the defining characteristic of every bug in this area.
Three details in that pattern earn their place:
The lookahead has to be there rather than in a second rule, because Next runs redirects() before rewrites(). At redirect time the URL is still /alcy/e/. There is no later stage where you can say "except the thing that is about to be proxied", so the exception lives in the only rule that runs first.
The trailing / outside the group is what makes this a trailing-slash rule at all. The group captures everything up to the final slash and the destination puts it back without one.
.+ rather than .* keeps the root out of it. With .*, / matches with an empty capture, the destination resolves to the empty string, and you have invented a redirect loop on your home page.
You can check all four behaviours from a terminal:
/ 200
/guides/how-to-find-youtube-influencers/ 308 -> same without the slash
/tools/engagement-rate-calculator/ 308 -> same without the slash
/alcy/decide/ 200
Those middle two are real pages, incidentally: how to find YouTube influencers and the engagement rate calculator. Seventy-six pages behave the ordinary way and one path does not.
The rewrites still have to be in that order
Unrelated to slashes but living in the same block, so worth repeating: PostHog serves its library from an assets host and takes events on an API host, and Next stops at the first matching rewrite. Put the catch-all first and every request for the library gets proxied to the ingestion endpoint, which answers with something that is not JavaScript. Analytics does not degrade in that scenario, it disappears, and nothing in your logs says why.
The general shape
Framework-wide URL normalisation is a setting with one value for the whole app, and sooner or later you proxy something whose URLs do not follow your convention. When that happens the move is not to fight the upstream into your shape. It is to turn the global behaviour off, write it back as an explicit rule, and put the exception in the rule that runs first.
One line of config, one redirect, one lookahead. The alternative was a 308 in front of every analytics event we will ever collect.
Top comments (0)