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

> A Vercel rewrite to an external host returned the site's own 404 when trailingSlash was true. The symptom, the explicit fix, curl checks and open questions.

Canonical: https://www.notchstar.com/blog/vercel-rewrite-trailing-slash-404/
Last updated: 2026-11-04

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

Published 2026-11-04.

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

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

```json
{ "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:

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

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

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

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

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

```bash
curl -s -X POST -H 'Content-Type: application/json' -d '{}' -D - -o /dev/null https://www.notchstar.com/ingest/capture
```

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

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

```bash
curl -s -X POST -H 'Content-Type: application/json' -d '{}' -D - https://www.notchstar.com/ingest/capture/
```

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

```bash
curl -s -X POST -d '{}' -D - https://www.notchstar.com/ingest/capture/x/
```

```text
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 endpoints | Reachable without a config change | Need a new rule |
| What the site forwards | Anything under the prefix | Exactly one URL |
| Behaviour in this project | Returned the site's 404 | Works (checked 4 November 2026) |
| Surprise factor | Forwards paths nobody reviewed | A 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:

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

| Source | Destination | What answers |
|---|---|---|
| `/ingest/capture/` | `https://eu.i.posthog.com/capture/` | PostHog, for the app's opt in daily event |
| `/ingest/site/` | `/api/analytics` | A Vercel function that validates and forwards the site's page counts |
| `/buy/` | `/api/buy` | A function that creates a Dodo Payments checkout and redirects to it |
| `/thanks/` | `/api/thanks` | A 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:

| Statement | Status | Evidence |
|---|---|---|
| Slashless paths get a 308 to the slash form | Known | `curl`, 4 November 2026 |
| The explicit external rewrite reaches PostHog | Known | PostHog's own error text in the response |
| Unmapped paths under `/ingest/` return the site's 404 | Known | `curl`, GET and POST |
| The wildcard returned the site's 404 | Recorded | Project notes, not reproduced |
| Why the wildcard failed | Not known | No 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`:

```bash
node -e "console.log(require('./vercel.json').rewrites)"
```

2. Send the real request, with the method and body your client uses, and print headers and the first lines of the body:

```bash
curl -s -X POST -H 'Content-Type: application/json' -d '{}' -D - https://example.com/ingest/capture/ | head -20
```

3. 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.
4. Request the slashless form and read the `location` header. A 308 means the slash redirect runs first.
5. Add a test that reads `vercel.json` and asserts the destination, so the rule survives refactors.

## Cheat sheet

| Check | Command | Healthy answer |
|---|---|---|
| Slash redirect | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://host/path` | 308 to the slash form |
| Proxy reaches upstream | `curl -s -X POST -d '{}' -D - https://host/ingest/capture/` | Upstream's own error text |
| No rule | same request to an unmapped path | Your 404 page, `filename="404.html"` |
| Rule exists | read `rewrites` in `vercel.json` | Literal 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](https://www.notchstar.com/). The privacy page lists the exact payload.

## More posts

- [Dodo Payments license API gotchas: 201 not 200, and the live host](https://www.notchstar.com/blog/dodo-payments-license-api-gotchas/)
- [Sensitive Vercel environment variables cannot be pulled back](https://www.notchstar.com/blog/vercel-sensitive-env-variables-cannot-be-pulled/)
