Vercel rewrites to an external host returned the site's own 404 with trailingSlash

Proxying analytics through your own domain, why a wildcard rewrite failed, and the explicit rule that replaced it

By Arin Prajapati 10 min read
Black cover with the NotchStar logo and the headline A rewrite that returned our own 404, with the line trailingSlash and an external host

TL;DR

  • On a Vercel site with "trailingSlash": true, a wildcard rewrite from /ingest/:path* to an external host answered with the site’s own 404 page instead of proxying. The fix that shipped maps each endpoint explicitly, with its trailing slash, to its exact destination.
  • The explicit rules work. Checked with curl on 4 November 2026: POST /ingest/capture/ reaches PostHog, which answers in its own words, and every other path under /ingest/ returns the site’s 404.
  • The exact cause of the wildcard failure was not isolated. The broken version was never committed, so there is no diff to show. This post says what is known, what was measured, and how to test a rewrite so the question can be answered in a minute next time.

Problem

The NotchStar app can send one anonymous event a day to PostHog (EU), but only if the user says yes. Calling PostHog directly would make the app talk to a third party host. Instead the app talks to the site’s own domain, and Vercel forwards the request. The source says so next to the endpoint:

/// Our own domain, so the app only ever talks to notchstar.com. Vercel rewrites /ingest to PostHog (EU).
static let endpoint = URL(string: "https://www.notchstar.com/ingest/capture/")!

The website used the same idea for its own cookieless counters, first through PostHog and later through a small function. A first party path has a practical benefit too: a request to your own domain is less likely to be dropped by content blockers than one to a known analytics host. That is a general property of proxying, not something measured here.

The obvious rewrite on Vercel is a wildcard:

{ "source": "/ingest/:path*", "destination": "https://eu.i.posthog.com/:path*" }

It is the natural first attempt for proxying a path prefix to another origin. In this project, with trailingSlash set to true, it returned the site’s own 404 page instead of reaching PostHog. This is the symptom recorded in the project notes (blog/FACTS.md, section B, item 6): a rewrite to an external host returned the site’s own 404 when trailingSlash was true.

The setup that matters

Two settings interact here. The site config:

{
  "cleanUrls": false,
  "trailingSlash": true,
  ...
}

and the Astro config, which builds every page as a directory with a canonical trailing slash:

export default defineConfig({
  site: 'https://www.notchstar.com',
  trailingSlash: 'always',
  build: { format: 'directory' },
  ...

With trailingSlash: true in vercel.json, a request for /buy is redirected to /buy/. That is easy to see from outside (4 November 2026):

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://www.notchstar.com/buy
308 https://www.notchstar.com/buy/

A 308 is a permanent redirect that keeps the method, which matters for a POST. The same applies to the ingest endpoint:

curl -s -X POST -H 'Content-Type: application/json' -d '{}' -D - -o /dev/null https://www.notchstar.com/ingest/capture
HTTP/2 308
content-type: text/plain
location: /ingest/capture/
server: Vercel

So the slashless form is bounced to the slash form before any rewrite sees it. The app calls the slash form directly, so it never takes that bounce.

What shipped

The rewrites in site/vercel.json today:

"rewrites": [
  { "source": "/ingest/site/",    "destination": "/api/analytics" },
  { "source": "/ingest/capture/", "destination": "https://eu.i.posthog.com/capture/" },
  { "source": "/buy/",            "destination": "/api/buy" },
  { "source": "/thanks/",         "destination": "/api/thanks" }
]

Two things stand out. Each source is a literal path with a trailing slash, and each destination is a literal path too, slash included. There are no parameters. And the file already uses the same style for the site’s own functions (/buy/ to /api/buy), so the external proxy and the internal ones look alike.

The wildcard is gone. Commit 2e35f2d on 7 October 2026 introduced the PostHog proxy, and the first version of the rewrite in the history is already the explicit one. A search of the whole history for the pattern (git log --all -S':path*', 4 November 2026) finds nothing, so the wildcard attempt never reached a commit. That is why this post cannot show the failing configuration as a diff: it exists only as a symptom in the notes.

Checking that the rewrite works

A rewrite is easy to test from outside, and the test should look at what answers, not only the status. On 4 November 2026:

curl -s -X POST -H 'Content-Type: application/json' -d '{}' -D - https://www.notchstar.com/ingest/capture/
HTTP/2 400
content-type: text/plain; charset=utf-8
server: Vercel
x-vercel-cache: MISS

failed to hydrate events from request: non-engage request missing event name attribute

A 400 is the right answer here. The body is an empty JSON object, and the text is PostHog complaining about it. It is not the site’s wording. That is how to tell a working proxy from a broken one: the body must come from the upstream.

Compare the same request to a path that has no rule:

curl -s -X POST -d '{}' -D - https://www.notchstar.com/ingest/capture/x/
HTTP/2 404
content-disposition: inline; filename="404.html"
content-type: text/html; charset=utf-8
server: Vercel
x-vercel-cache: HIT
<!DOCTYPE html><html lang="en" dir="ltr"> <head>...

That is the site’s own static 404 page, served from the cache. It is also what /ingest/, /ingest/other/ and /ingest/decide/ return (checked the same day, GET and POST). This is the signature of the original problem: a request that was meant for an external host and got the site’s 404.html. Nothing in the status line separates “the rewrite did not match” from “the upstream said not found”. The content-disposition: inline; filename="404.html" header and the HTML body do.

The function behind /ingest/site/ answers a POST with an empty body the same way a validating endpoint should, with a 400 (no HTML, x-vercel-cache: MISS), which shows that rule matches too.

Why explicit rules are safer here

The explicit style has a cost and a benefit, and both are real.

Wildcard /ingest/:path*Explicit rule per endpoint
New PostHog endpointsReachable without a config changeNeed a new rule
What the site forwardsAnything under the prefixExactly one URL
Behaviour in this projectReturned the site’s 404Works (checked 4 November 2026)
Surprise factorForwards paths nobody reviewedA missing rule is a visible 404

The cost is that every endpoint the app uses needs its own rule. The app uses one (capture), so that is cheap. The benefit is that the proxy cannot be used as an open relay to the whole upstream API. For an analytics proxy that a user’s privacy promise depends on, “forwards exactly one URL” is a property worth having even if the wildcard had worked.

A test pins it down so a later edit cannot quietly remove the rule:

assert.equal(
  vercel.rewrites.find((r: { source: string }) => r.source === '/ingest/capture/').destination,
  'https://eu.i.posthog.com/capture/',
  'the macOS app keeps its existing opt in endpoint');

It lives in site/tests/analytics.test.ts, alongside a check that the site’s own endpoint /ingest/site/ rewrites to /api/analytics. The docs page docs/ANALYTICS.md adds a human warning: “Do not repoint or remove that endpoint”, because shipped copies of the app have the URL compiled in.

What each rule is for

The four rules do different jobs, and only one of them leaves the site:

SourceDestinationWhat answers
/ingest/capture/https://eu.i.posthog.com/capture/PostHog, for the app’s opt in daily event
/ingest/site//api/analyticsA Vercel function that validates and forwards the site’s page counts
/buy//api/buyA function that creates a Dodo Payments checkout and redirects to it
/thanks//api/thanksA function that renders the page buyers return to

The second one shows why a first party route is more than cosmetics. The function in site/api/analytics.js answers a GET with 405, a foreign Origin with 403, a request with Do Not Track or Global Privacy Control with 204, a body over 8,192 bytes with 413, and a malformed payload with 400. It only accepts five event names, and it sends a loopback address in place of the visitor’s address. A browser can only reach that code because the request goes to the site’s own domain, where the site decides what is allowed. The external rule at /ingest/capture/ has none of those checks, because the request goes straight through to PostHog. The app’s payload is described on the privacy page, and a test in the app (AnalyticsTests) guards it.

What is known and what is not

Summary of the evidence, with where each item comes from:

StatementStatusEvidence
Slashless paths get a 308 to the slash formKnowncurl, 4 November 2026
The explicit external rewrite reaches PostHogKnownPostHog’s own error text in the response
Unmapped paths under /ingest/ return the site’s 404Knowncurl, GET and POST
The wildcard returned the site’s 404RecordedProject notes, not reproduced
Why the wildcard failedNot knownNo recorded rule or response headers

In words, what is known, from the code and from curl:

  • With trailingSlash: true, slashless paths get a 308 to the slash form before anything else.
  • The explicit external rewrite works, and unmatched paths under /ingest/ return the site’s static 404.
  • The failing wildcard was observed and recorded in the project notes, and replaced.

Not known:

  • The mechanism. Whether the wildcard failed because of how trailingSlash normalizes the matched path, because of the :path* pattern itself, or because of the external destination, was not isolated. Vercel’s configuration reference, fetched on 4 November 2026, lists rewrites (“Route requests to different paths or external URLs”) and trailingSlash (“Add or remove trailing slashes from URLs”) as separate properties and does not describe how they interact. Anyone who reproduces the failure should record the exact rule, the request and the response headers, because that is the evidence this post lacks.
  • Whether it still fails. The wildcard was not retried after the fix, so it is not known whether a current deployment would behave the same way.

Guessing at a cause here would turn into a claim, so the post stops at the observation.

The wrong turn

The wrong turn was the wildcard itself: one short rule that looks like it covers every endpoint, now and later. It is the form most people reach for, and it fails in a way that gives no clue, because the answer is a perfectly normal looking 404 page in the site’s own design. That looks like “the site is broken” and not like “the proxy did not match”.

The lesson that holds without knowing the cause is about testing. A rewrite to an external host should be tested with the same request the real client sends, a POST with a JSON body to the exact URL, and the response body should be read as well as the status. A GET from a browser tab asks a different question, because the ingest endpoint expects POST.

What is still bad

  • The 404 is indistinguishable by status. A missing rule and an upstream 404 look the same from the status line. Only the body and content-disposition differ.
  • No monitoring. Nothing watches that /ingest/capture/ still reaches PostHog. The test checks the config, not the running site. A deploy that drops vercel.json or the api folder would break the proxy silently until someone ran the curl above. The deploy notes in docs/ANALYTICS.md say “Never omit API functions or the app’s existing rewrite”, which is a human rule, not a check.
  • Opt in only. The app’s event is sent only if the user turned analytics on, so a broken proxy fails quietly and costs only some data.
  • The cause stays open. See above.

Try it yourself

You can test any rewrite on a site you control with three requests. Replace the host with yours.

  1. Make sure the rule exists and has the same trailing slash as the request. Read vercel.json:
node -e "console.log(require('./vercel.json').rewrites)"
  1. Send the real request, with the method and body your client uses, and print headers and the first lines of the body:
curl -s -X POST -H 'Content-Type: application/json' -d '{}' -D - https://example.com/ingest/capture/ | head -20
  1. Send the same request to a path with no rule and keep that output next to the first one. If both come back as your site’s 404 page, the rule did not match.
  2. Request the slashless form and read the location header. A 308 means the slash redirect runs first.
  3. Add a test that reads vercel.json and asserts the destination, so the rule survives refactors.

Cheat sheet

CheckCommandHealthy answer
Slash redirectcurl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://host/path308 to the slash form
Proxy reaches upstreamcurl -s -X POST -d '{}' -D - https://host/ingest/capture/Upstream’s own error text
No rulesame request to an unmapped pathYour 404 page, filename="404.html"
Rule existsread rewrites in vercel.jsonLiteral source and destination
  • Test rewrites with the real method, body and URL, never only a browser
  • Keep the trailing slash identical in the source, the destination and the client
  • Read the response body, not only the status
  • Map each endpoint explicitly when the proxy carries privacy sensitive traffic
  • Add a test on the config and a note that shipped clients depend on the URL
  • Record the exact rule and response when something fails, so the cause can be found

If you want to see the app that sends this one daily event when you allow it, NotchStar is at notchstar.com. The privacy page lists the exact payload.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.