I shipped a one-line fix for a soft 404 yesterday. The commit is on main, it is deployed on the box, and the compiled server chunk contains it. The bug is still live. A garbage id under /vault/ answers HTTP 200 with a Not Found body, while the same garbage under /blog/ and /newsletter/archive/ answers 404. I checked four places. Three of them lied to me.
for p in /vault /blog /newsletter/archive; do
curl -sS -o /dev/null -w "%{http_code} $p\n" \
"https://thechosenvictor.com$p/zzz-does-not-exist-9x7"
done
# 200 /vault <- still broken
# 404 /blog
# 404 /newsletter/archive| Where I checked | What it said | Was it right? |
|---|---|---|
| Source on the server | dynamicParams = false | No |
| Deployed commit | d65e7b6, tree clean | No |
| Compiled chunk | dynamicParams:!1 | No |
| curl to the running site | 200 on an unknown id | Yes |
The gap is the build manifest. Next.js writes prerender-manifest.json at build time, and the router reads that file, not your source, to decide how a route behaves. /vault/[id] is missing from its dynamicRoutes list while both working siblings sit there with fallback false. So the router never learns it is a no-fallback route, falls through to on-demand rendering, runs notFound(), and returns that body with a 200. The flag is in the code, not in the thing that reads the code.
- Step 01
Ask what the running site returns, not what the repo contains
Source, commit hash, and compiled output describe intent. Only a request observes behaviour. When they disagree, the request wins.
- Step 02
Check the artifact your framework actually reads
For the Next.js App Router that is .next/prerender-manifest.json. If your route is missing from dynamicRoutes while its siblings are there, the config never reached the router.
- Step 03
Put the assertion after the deploy, not in the test suite
I have a passing test for this. It asserts the source exports the flag, which is true, so it cannot fail on this bug. A post-deploy curl against a random id, expecting 404, catches it the moment it ships.