I was checking how CogniPrep answers a URL that does not exist, expecting a boring result. Six families gave me the boring result:
curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/tests/zzz # 404 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/employers/zzz # 404 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/blogs/zzz # 404 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/interview/zzz # 404 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/cheating/zzz # 404 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/application-tracker/zzz # 404 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/games/zzz # 200 Each of those routes calls notFound() on an unknown slug. The code in the seventh looks like the code in the others:
const game = GAME_LIBRARY.find((g) => g.id === gameName); if (!game) notFound(); It still answers 200.
What the 200 contains
curl -s https://cogniprep.app/games/zzz | grep -o '<title>[^<]*' # <title>Game Not Found | CogniPrep curl -s https://cogniprep.app/games/zzz | grep -o 'name="robots" content="[^"]*"' # name="robots" content="index, follow" # name="robots" content="noindex" Two robots tags that disagree, and a title from the route itself rather than from the not-found page. Compare the same two greps on /tests/zzz:
<title>Page Not Found | CogniPrep name="robots" content="noindex" name="robots" content="noindex, follow" So on the working routes the 404 owns the whole document. On /games/zzz the route got there first: its generateMetadata ran, found no game, returned { title: 'Game Not Found' } and nothing else, so the site default of index, follow was applied to the page, the head was committed, and by the time the page function threw its notFound() the only thing left to replace was the body. The noindex you can see in second place is the not-found page arriving too late to be the only voice in the room.
Why that route and not the other six
The difference is not the code, it is when the code runs. The six well-behaved families are rendered ahead of the request. The seventh reads a session cookie to decide whether you may play, which makes it dynamic, and a dynamic response starts going out before the page body has finished deciding anything.
notFound() and redirect() are not return values. They throw, and a thrown status is only honoured while no status has been sent.
The same route proves it a second way. Ask for a game that really exists, signed out:
curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/games/balloon # 200 curl -s https://cogniprep.app/games/balloon | grep -o '__next-page-redirect' # __next-page-redirect That page calls redirect('/login'). What actually ships is a 200 whose visible text is "Loading game...", carrying Next's client-side redirect marker, which the browser then acts on. Correct destination, correct noindex, nofollow, wrong status code, and no status code at all for a client that does not run scripts.
One route in the app gets this right, and it is the one that does not try. /exercises/anything returns a clean 307 to /login, because /exercises is in the middleware's protected prefix list and the decision is made before any rendering starts. Middleware still owns the status. A page body does not.
The crawl rules make it funnier
Our robots.txt is 836 lines and 24,353 bytes. Line 5 is:
Allow: /games/ 744 of the remaining lines are Disallow: /games/<game-id>, which is 372 real games listed twice, once per user-agent group. The finite set of URLs that exist is carved out by name. The infinite set of URLs that do not exist is explicitly allowed.
Case variants land in that infinite set, because the lookup is an exact match on the id:
curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/games/SHL # 200 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/games/Balloon # 200 curl -s -o /dev/null -w '%{http_code}\n' https://cogniprep.app/Pricing # 404 /Pricing is a static route, so it fails properly. /games/SHL is a soft 404 with a 200 next to a perfectly good provider page at /games/shl.
Two things keep this from being an SEO incident. Search engines combine multiple robots meta tags on one page and take the most restrictive, so the stray noindex wins and none of these URLs should enter an index. And nothing links to them, so discovery depends on somebody typing one. What it does cost is crawl budget and a growing soft-404 report, which is the kind of thing you find out about six months late.
The fix, and the general rule
Validate before the response starts. Either decide in middleware, the way the protected prefixes already do, or make the unknown-slug case fail inside generateMetadata, which runs while the status is still mutable. For us the first option is better, because the route's whole reason for being dynamic is an auth check that middleware is already doing elsewhere.
The rule I am taking away: in a dynamic route, notFound() and redirect() are suggestions about the body, not promises about the status line. If the status matters, and for a 404 it does, decide before the first byte.
See it
Run the seven curls at the top. Six return 404, one returns 200. Then look at the two pages the broken one sits between: cogniprep.app/games is the real index of 56 assessment providers, and cogniprep.app/tests is the family that gets its status code right. Same site, same not-found component, two different answers, and the only difference is whether the page needed to know who you are.
