ADR-0034 — The proxy answers unknown paths with a true 404
The proxy answers every unknown path with a 404 status and the whole out-of-orbit page from the first request, instead of a blank 200.
- Status: Approved
- Date: 2026-11-03
- Deciders: @puncoz
- Approved by: @puncoz
- Approved on: 2026-11-03
- Supersedes: ADR-0015 (in part: its implementation's "the proxy acts only on catalogued paths")
- Superseded by: —
- Design documents:
../app-flow/unknown-paths.mdx,../api/content-routes.mdx,../architecture/system.mdx
Context
-
Every path that is not a site route reaches
src/app/(site)/(docs)/[...slug]/page.tsx, which callsnotFound()when it has no such page. -
That call comes too late for a path
generateStaticParamsdid not list. Measured onbunx next build, for a path not requested before:Request Status HTML First /no-such-page200 Blank: <html id="__next_error__">, no page contentThe same path again 404 Blank, the same shell First /x/y/z,/docs/nothing-here200 Blank, the same shell The route's prerendered fallback shell is empty, and the page learns the path is unknown only when it resumes. Scripts then build the 404 in the browser, with a
noindextag. -
So a reader without scripts sees a blank page, which contradicts the 404's documented state (Back to the system still works). Monitoring counts these as successes, some crawlers report soft 404s, and in Chrome the first hit's tab showed no title although the title is in the HTML.
-
The installed Next.js documents this. A streamed page keeps its 200, and for a 404 "you can check the slug in Proxy, then rewrite it to a not-found route or return a 404 response" (
file-conventions/loading.md, Status codes). -
dynamicParams = false, which would refuse unlisted paths, is not available with Cache Components (ADR-0016;route-segment-config/dynamicParams.md). -
The proxy has no
matcher, so it runs on every request, including_nextand files. It already answers line redirects and Markdown, but only for catalogued slugs, and it knows onlymeta.jsonfiles:src/lib/catalog-routes.tskeeps compiled pages out of its bundle. -
fumadocs-mdx/macrohas anasyncmode (docs: { async: true }): frontmatter is imported at once and the compiled body only when asked for (transform-*.js,only: "frontmatter").
Decision
- The proxy answers every unknown path with a true 404, before any page renders. The status
is 404 from the first request and the response is the whole out-of-orbit page, the existing
src/app/not-found.tsx, in the HTML. The address does not change, and the page still shows no part of it. - What it judges. Only paths
[...slug]would otherwise render. These pass through untouched:/,/_next/…, the site's own routes (api,diagrams,llms-full.txt,llms.mdx,llms.txt,og,static.json),icon.svgand any file inpublic/. - What it knows. Every page's URL, and each
/<slug>/useand line root that redirects to a first page (ADR-0015 decision 1). Comparison is exact, so/DOCSis unknown. - The existing order stays. Line redirects and Markdown come first, so a retired line still
redirects and
/<unknown>.mdstill gets its own 404 from the Markdown route. - The list comes from the content, and the build checks it.
catalog-routes.tslists the pages' paths through anasynccollection, so no compiled page runs in the proxy, and works out each URL with the same line segments assource.ts. The build fails if this list andsource.getPages()differ, because a page wrongly refused would be a real page gone. notFound()in the page stays, as the safety net for anything that reaches it.
Options considered
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Check every path in the proxy | True 404 with full HTML everywhere; what Next.js documents | The proxy must know the pages; a wrong list would hide a real page, so it needs a build check | Chosen (owner) |
| Check only the first segment | Uses data the proxy already has | /docs/nothing-here stays a blank 200 | Rejected (owner) |
| Leave it | No work; noindex already keeps these out of search | Blank without scripts, 200 in monitoring | Rejected (owner) |
dynamicParams = false | One line, no list | Not available with Cache Components | Not possible |
Generate the list in a script, or in next.config.mjs | No macro involved | A new build step, and it can go stale in development | Rejected |
Read content/ from the proxy at request time | Always current | Slow, and the content need not be on the server | Rejected |
| Fetch from a route in the proxy | Reuses source | A request per request; Next.js advises against it | Rejected |
Consequences
Positive
- Unknown paths are true 404s for readers, tools and monitoring, and readable without scripts.
- The 404's documented degraded state becomes true.
Negative / accepted trade-offs
- The proxy gains a second view of the pages. The build check is what makes that safe.
- It runs one set lookup on each request it judges.
- The
asynccollection adds lazily loaded chunks to the proxy's bundle. They are not run, but their size is unmeasured until this is built. - A new top-level route must be added to the proxy's pass-through list, or it would answer 404. A
test compares that list with
src/app/.
Content invariants
§6.5 is kept: Markdown routes are not changed. No content is added or exposed. The URL list holds only pages that are already published.
Security considerations
Low. The request's path is only compared with a fixed set, and it is never echoed, so the page
still shows no part of it. Nothing leaves the system and no secret is touched. The proxy's bundle
holds the same public URLs as llms.txt. An unknown path now gets a cached static page, which
costs less than a render. The threat model is checked on implementation.
Implementation notes
-
First, verified with a spike on
bunx next build: a proxy rewrite to/_not-foundwith status 404 serves the whole page on the first request, for document and_rscrequests alike. The ADR's other route was not needed. -
Measured the proxy's traced chunks: 512 KB before; 2.9 MB with a plain
asynccollection, because it compiles a lazy chunk with the text of every page, which the proxy never loads. That was out of proportion, so the collection incatalog-routes.tscompiles each body empty, with a one-line remark plugin of its own. The result is 585 KB, with no page text in any proxy chunk, and the site's own collection keeps its bodies. The chunk loaded at start grows by about 20 KB, the frontmatter. A request took 1.5ms at the median through the proxy. -
src/lib/known-paths.ts: the site routes,knownPathsandknownPathProblems, without the macro so they can be tested.src/lib/catalog-routes.ts: the collection,isKnownPathandassertKnownPaths.src/proxy.ts: the check, after the existing steps. -
lineSlugsinsrc/lib/catalog-plugin.ts, used bysource.tsandknown-paths.ts, so both work out a line's URL the same way. -
The build check of decision 5:
checkKnownPathsinsrc/lib/catalog.ts, called from the page'sgenerateStaticParams, comparing the list withsource.getPages()and the catalogue's landings. Dropping one page from the list failsbunx next buildand names that page. -
Tests (ADR-0007), in
src/lib/known-paths.test.ts: known pages, landings, line segments, unknown paths at each depth, the wrong case and trailing slashes; each pass-through; the pass-through list againstsrc/app/andpublic/; the check failing on each kind of difference. Checked withcurlagainstbunx next build:- the Context table's paths give 404 with the page in the HTML on the first request, and no part of the path in it;
- the 93 pages in
llms.txtand instatic.jsongive 200; - the site's routes and files are unchanged,
/<entry>/<unknown>.mdstill gets the Markdown route's 404, and/docs/still redirects to/docs.
In Chrome: the tab's title on a first hit; the page with scripts off, in a frame sandboxed without
allow-scripts, whole and with its links; a client-side push to an unknown path; seven real prefetches, all 200; Back to the system and an ordinary link staying client-side.In development, against
next dev: a page added tocontent/answered 200 within four seconds, and 404 again within four seconds of being removed, so the proxy's list does not go stale there. -
Found while checking:
- A client-side link to an unknown path now loads the whole page rather than swapping it in,
because Next.js does that for any RSC response that is not OK
(
fetch-server-response.js). The reader lands on the same page. - The 404 is served with
Cache-Control: s-maxage=31536000, as a static page is. A shared cache that keeps it across a deploy would hide a page added later. No host is adopted yet; the ADR that adopts one must check this. - Decision 2 leaves everything below a site route alone, so a path such as
/api/nopeor/diagrams/nope, which only[...slug]matches, is still a blank 200./og/nopeis a 404 from its own route. ADR-0035 later removedapifrom the site routes, since no such route exists, so/api/nopeis now a true 404;/diagrams/nopeand/llms.txt/xare still blank 200s.
- A client-side link to an unknown path now loads the whole page rather than swapping it in,
because Next.js does that for any RSC response that is not OK
(
-
bun run test,bun run types:checkandbunx next buildpass;bun run lintis reported while it stays blocked.
Docs to update on implementation
-
../app-flow/unknown-paths.mdx: Current. -
../api/content-routes.mdx: the 404 response. -
../architecture/system.mdx: the routes table and what the proxy acts on, with the diagram's Archify pair;content/docs/maintain/security/threat-model.mdx. -
AGENTS.md§1, §5 and §10.PROJECT_AGENTS.md: no change; a consuming repository's pages are listed from the content. - ADR-0015's "Superseded by" line, and
./index.mdx: index row and status.
ADR-0033 — The footer says you've reached the horizon
The footer's closing line becomes "You've reached the horizon. The Verse goes on.", and The Verse links to the Verse view.
ADR-0035 — Every page is ready for search and sharing
Canonical links, a sitemap, robots rules, structured data, descriptions and designed share images for every page, built from the content.